mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
fix(react-core): improve Inspector message shortcuts
This commit is contained in:
@@ -331,7 +331,7 @@ function Chat({
|
||||
id: "local-inspector-preview",
|
||||
role: "assistant",
|
||||
content:
|
||||
"This local preview lets you open the CopilotKit Inspector directly from an assistant response. Hover over the CopilotKit mark below, then click it to inspect the current run.",
|
||||
"This local preview lets you open the CopilotKit Inspector directly from an assistant response. Hover over the wrench icon below, then click it to inspect the current run.",
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 32 }}>{input}</div>
|
||||
|
||||
@@ -15,6 +15,11 @@ vi.mock("@copilotkit/web-inspector", () => {
|
||||
connectedCallback() {
|
||||
this.coreAtConnection = this.core;
|
||||
this.autoAttachCoreAtConnection = this.autoAttachCore;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("cpk-inspector-visibility-change", {
|
||||
detail: { visible: true },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,23 @@ import type { CopilotKitInspectorOpenRequest } from "./CopilotKitInspectorContex
|
||||
|
||||
export interface CopilotKitInspectorProps {
|
||||
core?: CopilotKitCore | null;
|
||||
onVisibilityChange?: (visible: boolean) => void;
|
||||
openRequest?: CopilotKitInspectorOpenRequest | null;
|
||||
}
|
||||
|
||||
export const CopilotKitInspector: React.FC<CopilotKitInspectorProps> = ({
|
||||
core,
|
||||
openRequest,
|
||||
onVisibilityChange,
|
||||
}) => {
|
||||
const mountRef = React.useRef<HTMLSpanElement | null>(null);
|
||||
const inspectorRef = React.useRef<WebInspectorElement | null>(null);
|
||||
const latestCoreRef = React.useRef(core ?? null);
|
||||
const latestOpenRequestRef = React.useRef(openRequest);
|
||||
|
||||
const visibilityCallbackRef = React.useRef(onVisibilityChange);
|
||||
visibilityCallbackRef.current = onVisibilityChange;
|
||||
|
||||
latestCoreRef.current = core ?? null;
|
||||
latestOpenRequestRef.current = openRequest;
|
||||
|
||||
@@ -24,6 +29,12 @@ export const CopilotKitInspector: React.FC<CopilotKitInspectorProps> = ({
|
||||
let mounted = true;
|
||||
let inspector: WebInspectorElement | null = null;
|
||||
|
||||
const handleVisibilityChange = (event: Event) => {
|
||||
const visible = (event as CustomEvent<{ visible: boolean }>).detail
|
||||
?.visible;
|
||||
visibilityCallbackRef.current?.(visible === true);
|
||||
};
|
||||
|
||||
// Load the web component only on the client to keep SSR output stable.
|
||||
void import("@copilotkit/web-inspector")
|
||||
.then((mod) => {
|
||||
@@ -35,6 +46,10 @@ export const CopilotKitInspector: React.FC<CopilotKitInspectorProps> = ({
|
||||
) as WebInspectorElement;
|
||||
mod.configureWebInspectorElement(inspector, latestCoreRef.current);
|
||||
|
||||
inspector.addEventListener(
|
||||
"cpk-inspector-visibility-change",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
mountRef.current.appendChild(inspector);
|
||||
inspectorRef.current = inspector;
|
||||
|
||||
@@ -49,7 +64,12 @@ export const CopilotKitInspector: React.FC<CopilotKitInspectorProps> = ({
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
inspector?.removeEventListener(
|
||||
"cpk-inspector-visibility-change",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
inspector?.remove();
|
||||
visibilityCallbackRef.current?.(false);
|
||||
if (inspectorRef.current === inspector) {
|
||||
inspectorRef.current = null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export type CopilotKitInspectorOpenRequest = {
|
||||
};
|
||||
|
||||
type CopilotKitInspectorContextValue = {
|
||||
/** Explicit provider preference, which takes priority over chat preferences. */
|
||||
providerEnableInspector?: boolean;
|
||||
isInspectorEnabled: boolean;
|
||||
openInspector: (request: CopilotKitInspectorOpenRequest) => void;
|
||||
};
|
||||
|
||||
@@ -47,6 +47,10 @@ import {
|
||||
import { LastUserMessageContext } from "./last-user-message-context";
|
||||
import type { LastUserMessageState } from "./last-user-message-context";
|
||||
import { useInspectorThreadOverride } from "../../providers/use-inspector-thread-override";
|
||||
import {
|
||||
CopilotKitInspectorContextProvider,
|
||||
useCopilotKitInspector,
|
||||
} from "../CopilotKitInspectorContext";
|
||||
|
||||
export type CopilotChatProps = Omit<
|
||||
CopilotChatViewProps,
|
||||
@@ -67,6 +71,12 @@ export type CopilotChatProps = Omit<
|
||||
agentId?: string;
|
||||
threadId?: string;
|
||||
labels?: Partial<CopilotChatLabels>;
|
||||
/**
|
||||
* Enable Inspector message shortcuts for this chat (enabled by default).
|
||||
* An explicit CopilotKit provider enableInspector value takes priority.
|
||||
* Shortcuts only appear in local development while Inspector is visible.
|
||||
*/
|
||||
inspectorTools?: boolean;
|
||||
chatView?: SlotValue<typeof CopilotChatView>;
|
||||
isModalDefaultOpen?: boolean;
|
||||
/** Enable multimodal file attachments (images, audio, video, documents). */
|
||||
@@ -98,6 +108,7 @@ export function CopilotChat({
|
||||
agentId,
|
||||
threadId,
|
||||
labels,
|
||||
inspectorTools,
|
||||
chatView,
|
||||
isModalDefaultOpen,
|
||||
attachments: attachmentsConfig,
|
||||
@@ -107,6 +118,16 @@ export function CopilotChat({
|
||||
}: CopilotChatProps) {
|
||||
// Check for existing configuration provider
|
||||
const existingConfig = useCopilotChatConfiguration();
|
||||
const inspector = useCopilotKitInspector();
|
||||
const inspectorContextValue = useMemo(
|
||||
() => ({
|
||||
...inspector,
|
||||
isInspectorEnabled:
|
||||
inspector.isInspectorEnabled &&
|
||||
(inspector.providerEnableInspector ?? inspectorTools ?? true),
|
||||
}),
|
||||
[inspector, inspectorTools],
|
||||
);
|
||||
|
||||
// Apply priority: props > existing config > defaults
|
||||
const providerAgentId = useDefaultAgentId();
|
||||
@@ -1201,7 +1222,9 @@ export function CopilotChat({
|
||||
</div>
|
||||
)}
|
||||
<LastUserMessageContext.Provider value={lastUserMessageState}>
|
||||
{RenderedChatView}
|
||||
<CopilotKitInspectorContextProvider value={inspectorContextValue}>
|
||||
{RenderedChatView}
|
||||
</CopilotKitInspectorContextProvider>
|
||||
</LastUserMessageContext.Provider>
|
||||
</div>
|
||||
</CopilotChatConfigurationProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantMessage, Message } from "@ag-ui/core";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
@@ -26,6 +26,10 @@ import { Streamdown } from "streamdown";
|
||||
import { copyToClipboard } from "@copilotkit/shared";
|
||||
import CopilotChatToolCallsView from "./CopilotChatToolCallsView";
|
||||
import { useCopilotKitInspector } from "../CopilotKitInspectorContext";
|
||||
import {
|
||||
CopilotChatInspectorButton,
|
||||
useInspectorShortcutsHidden,
|
||||
} from "./CopilotChatInspectorButton";
|
||||
import type { CopilotKitInspectorOpenRequest } from "../CopilotKitInspectorContext";
|
||||
|
||||
export type CopilotChatFeedbackMessage = AssistantMessage & {
|
||||
@@ -116,6 +120,8 @@ export function CopilotChatAssistantMessage({
|
||||
}: CopilotChatAssistantMessageProps) {
|
||||
useKatexStyles();
|
||||
const { isInspectorEnabled, openInspector } = useCopilotKitInspector();
|
||||
const shortcutsHidden = useInspectorShortcutsHidden();
|
||||
const showInspectorShortcut = isInspectorEnabled && !shortcutsHidden;
|
||||
|
||||
const boundMarkdownRenderer = renderSlot(
|
||||
markdownRenderer,
|
||||
@@ -146,12 +152,14 @@ export function CopilotChatAssistantMessage({
|
||||
},
|
||||
);
|
||||
|
||||
const boundInspectorButton = (
|
||||
const boundInspectorButton = showInspectorShortcut ? (
|
||||
<BoundInspectorButton
|
||||
inspectorButton={inspectorButton}
|
||||
messageId={message.id}
|
||||
openInspector={openInspector}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
||||
const boundThumbsDownButton = renderSlot(
|
||||
@@ -183,14 +191,14 @@ export function CopilotChatAssistantMessage({
|
||||
CopilotChatAssistantMessage.Toolbar,
|
||||
{
|
||||
children: (
|
||||
<div className="cpk:flex cpk:items-center cpk:gap-1">
|
||||
<div className="cpk:flex cpk:w-full cpk:items-center cpk:gap-1">
|
||||
{boundCopyButton}
|
||||
{isInspectorEnabled && boundInspectorButton}
|
||||
{(onThumbsUp || thumbsUpButton) && boundThumbsUpButton}
|
||||
{(onThumbsDown || thumbsDownButton) && boundThumbsDownButton}
|
||||
{(onReadAloud || readAloudButton) && boundReadAloudButton}
|
||||
{(onRegenerate || regenerateButton) && boundRegenerateButton}
|
||||
{additionalToolbarItems}
|
||||
{showInspectorShortcut && boundInspectorButton}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -212,7 +220,7 @@ export function CopilotChatAssistantMessage({
|
||||
messages?.[messages.length - 1]?.id === message.id;
|
||||
const shouldShowToolbar =
|
||||
toolbarVisible &&
|
||||
(hasContent || isInspectorEnabled) &&
|
||||
(hasContent || showInspectorShortcut) &&
|
||||
!(isRunning && isLatestAssistantMessage);
|
||||
|
||||
if (children) {
|
||||
@@ -262,118 +270,6 @@ export function CopilotChatAssistantMessage({
|
||||
);
|
||||
}
|
||||
|
||||
function CopilotKitColoredIcon() {
|
||||
const gradientId = useId().replace(/:/g, "");
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="cpk:size-5"
|
||||
data-testid="copilot-inspector-icon"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8.162 7.758c2.093-2.738 3.831-5.445 4.498-7.63a.093.093 0 01.14-.051c2.324 1.539 6.558 2.552 10.301 2.576a.09.09 0 01.085.124c-1.243 3.158-2.765 8.817-2.823 15.28-.001.095-.135.13-.183.046-2.131-3.729-8.955-8.968-11.982-10.205a.09.09 0 01-.036-.14z"
|
||||
fill={`url(#${gradientId}-purple)`}
|
||||
/>
|
||||
<path
|
||||
d="M15.223 6.083A61.492 61.492 0 018.25 7.827c-.045.008-.055.071-.012.089 3.05 1.267 9.84 6.492 11.952 10.206a.017.017 0 00.022.007.018.018 0 00.01-.024l-4.999-12.02z"
|
||||
fill={`url(#${gradientId}-blue)`}
|
||||
/>
|
||||
<path
|
||||
d="M12.81.07c2.8 1.528 6.037 2.214 10.33 2.575.028.002.036.039.012.051-.55.282-3.695 1.883-6.03 2.74-.626.23-1.256.443-1.876.64a.028.028 0 01-.033-.016L12.746.128c-.017-.04.027-.078.065-.058z"
|
||||
fill={`url(#${gradientId}-light-blue)`}
|
||||
/>
|
||||
<path
|
||||
className="cpk:fill-[#513C9F] cpk:dark:fill-[#B99AE8]"
|
||||
d="M12.725.075c.046-.019.1.003.119.05l7.514 17.923a.091.091 0 01-.148.1l-.02-.03L12.675.195a.091.091 0 01.049-.12z"
|
||||
/>
|
||||
<path
|
||||
className="cpk:fill-[#513C9F] cpk:dark:fill-[#B99AE8]"
|
||||
d="M23.06 2.66c.044-.025.1-.01.125.034.025.044.009.1-.035.124v.001l-.008.004-.025.015-.1.054a41.384 41.384 0 01-1.811.92A47.05 47.05 0 0116.33 5.82c-1.954.674-3.97 1.197-5.497 1.552a66.27 66.27 0 01-2.38.507l-.138.026-.036.007h-.01l-.002.002a.091.091 0 11-.033-.18l.016.09-.015-.09h.002l.01-.002.035-.007.137-.025a66.16 66.16 0 002.373-.506c1.524-.354 3.533-.876 5.479-1.547a46.857 46.857 0 006.276-2.709c.166-.087.295-.156.381-.204l.099-.054.024-.014.008-.004z"
|
||||
/>
|
||||
<path
|
||||
className="cpk:fill-[#ABABAB] cpk:dark:fill-[#D4D4D4]"
|
||||
d="M13.838 2.272a.16.16 0 01.107.2l-2.72 9.055h6.4l.061.013a.16.16 0 010 .295l-.061.013h-6.541L.679 24.099l-.05.04a.16.16 0 01-.194-.245l10.43-12.285 2.773-9.23a.16.16 0 01.2-.107z"
|
||||
/>
|
||||
<path
|
||||
d="M7.809 21.461l-1.232.173c.638 1.69 1.949 2.427 3.514 2.427 3.831 0 2.661-4.334 4.883-4.334 1.61 0 .956 3.513 4.423 3.513 2.116 0 2.326-2.131 1.966-3.048l-.008-.016-.567-.868c-.037-.058-.127-.036-.133.032l-.106 1.053a1.01 1.01 0 00.003.219c.088.727.144 2.491-1.155 2.491-1.37 0-1.7-3.467-4.423-3.467-3.196 0-2.785 4.289-4.747 4.289-1.294 0-2.28-1.46-2.418-2.464z"
|
||||
fill={`url(#${gradientId}-tail)`}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={`${gradientId}-purple`}
|
||||
x1="17.852"
|
||||
x2="14.202"
|
||||
y1="1.467"
|
||||
y2="11.504"
|
||||
>
|
||||
<stop className="cpk:[stop-color:#6430AB] cpk:dark:[stop-color:#B792F0]" />
|
||||
<stop
|
||||
className="cpk:[stop-color:#AA89D8] cpk:dark:[stop-color:#D3BDF7]"
|
||||
offset="1"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={`${gradientId}-blue`}
|
||||
x1="15.024"
|
||||
x2="10.324"
|
||||
y1="7.125"
|
||||
y2="16.204"
|
||||
>
|
||||
<stop className="cpk:[stop-color:#005DBB] cpk:dark:[stop-color:#4D9FEF]" />
|
||||
<stop
|
||||
className="cpk:[stop-color:#3D92E8] cpk:dark:[stop-color:#84C0FA]"
|
||||
offset="1"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={`${gradientId}-light-blue`}
|
||||
x1="17.122"
|
||||
x2="15.707"
|
||||
y1="1.467"
|
||||
y2="5.892"
|
||||
>
|
||||
<stop className="cpk:[stop-color:#1B70C4] cpk:dark:[stop-color:#61ACF2]" />
|
||||
<stop
|
||||
className="cpk:[stop-color:#54A4F2] cpk:dark:[stop-color:#9ACDFF]"
|
||||
offset="1"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={`${gradientId}-tail`}
|
||||
x1="6.577"
|
||||
x2="21.506"
|
||||
y1="21.758"
|
||||
y2="21.758"
|
||||
>
|
||||
<stop className="cpk:[stop-color:#4497EA] cpk:dark:[stop-color:#79BCF5]" />
|
||||
<stop
|
||||
className="cpk:[stop-color:#1463B2] cpk:dark:[stop-color:#4594D8]"
|
||||
offset=".255"
|
||||
/>
|
||||
<stop
|
||||
className="cpk:[stop-color:#0A437D] cpk:dark:[stop-color:#347CB7]"
|
||||
offset=".499"
|
||||
/>
|
||||
<stop
|
||||
className="cpk:[stop-color:#2476C8] cpk:dark:[stop-color:#58A4E5]"
|
||||
offset=".667"
|
||||
/>
|
||||
<stop
|
||||
className="cpk:[stop-color:#0C549A] cpk:dark:[stop-color:#3C87C7]"
|
||||
offset=".973"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace CopilotChatAssistantMessage {
|
||||
export const MarkdownRenderer: React.FC<
|
||||
@@ -482,28 +378,7 @@ export namespace CopilotChatAssistantMessage {
|
||||
|
||||
export const InspectorButton: React.FC<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
> = ({ title, className, ...props }) => {
|
||||
const config = useCopilotChatConfiguration();
|
||||
const labels = config?.labels ?? CopilotChatDefaultLabels;
|
||||
const primaryLabel = title || labels.assistantMessageToolbarInspectorLabel;
|
||||
const accessibleLabel = `${primaryLabel} (local only)`;
|
||||
return (
|
||||
<ToolbarButton
|
||||
data-testid="copilot-inspector-button"
|
||||
title={accessibleLabel}
|
||||
className={twMerge("cpk:w-auto cpk:gap-1.5 cpk:px-2", className)}
|
||||
tooltipClassName="cpk:max-w-64 cpk:text-left cpk:leading-4"
|
||||
tooltip="View this message in the Inspector to get more information. This button and the inspector only display during local development (localhost, dev env)."
|
||||
{...props}
|
||||
>
|
||||
<CopilotKitColoredIcon />
|
||||
<span className="cpk:font-medium">{primaryLabel}</span>
|
||||
<span className="cpk:text-xs cpk:text-muted-foreground">
|
||||
(local only)
|
||||
</span>
|
||||
</ToolbarButton>
|
||||
);
|
||||
};
|
||||
> = CopilotChatInspectorButton;
|
||||
|
||||
export const ThumbsUpButton: React.FC<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { EyeOff, Wrench } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { useCopilotKitInspector } from "../CopilotKitInspectorContext";
|
||||
import {
|
||||
CopilotChatDefaultLabels,
|
||||
useCopilotChatConfiguration,
|
||||
} from "../../providers/CopilotChatConfigurationProvider";
|
||||
|
||||
// Page-lifetime preference, shared by every message and chat. Deliberately
|
||||
// avoid sessionStorage: it survives reloads, while this preference must not.
|
||||
let shortcutsHidden = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
const getSnapshot = () => shortcutsHidden;
|
||||
const getServerSnapshot = () => false;
|
||||
|
||||
export function useInspectorShortcutsHidden() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
function hideShortcuts() {
|
||||
shortcutsHidden = true;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
// Matches the Inspector launcher artwork in web-inspector/src/assets/inspector-logo-kite.svg.
|
||||
function InspectorKiteIcon({
|
||||
className,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement>) {
|
||||
const id = useId();
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
aria-hidden="true"
|
||||
className={twMerge("cpk:size-4", className)}
|
||||
viewBox="4.57 3.36 17.8 17.8"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M6.36084 10.9855C8.34277 8.393 9.98843 5.82939 10.6204 3.75914C10.6382 3.70281 10.7043 3.67888 10.7534 3.7114C12.9536 5.16894 16.9635 6.12833 20.5086 6.15085C20.5703 6.15124 20.6124 6.2114 20.5895 6.26829C19.4109 9.25938 17.9705 14.6189 17.9148 20.7392C17.9148 20.8301 17.7873 20.8627 17.7419 20.7837C15.7236 17.2522 9.26021 12.2898 6.39414 11.1186C6.34112 11.0968 6.32556 11.0313 6.36084 10.9855Z"
|
||||
fill={`url(#${id}-paint0_linear)`}
|
||||
/>
|
||||
<path
|
||||
d="M13.0475 9.39974C9.95016 10.3806 7.11935 10.9259 6.44331 11.0498C6.40027 11.0577 6.39115 11.1172 6.43152 11.134C9.3203 12.3347 15.7516 17.2826 17.7511 20.7998C17.7551 20.8075 17.7647 20.8103 17.7728 20.8068C17.7809 20.803 17.7853 20.793 17.7819 20.7844L13.0475 9.39974Z"
|
||||
fill={`url(#${id}-paint1_linear)`}
|
||||
/>
|
||||
<path
|
||||
d="M10.762 3.705C13.4137 5.15161 16.4787 5.80132 20.545 6.14367C20.5703 6.14585 20.5787 6.18008 20.5557 6.19197C20.0359 6.45923 17.0574 7.97512 14.8455 8.78701C14.2524 9.00453 13.6564 9.20632 13.0692 9.39249C13.0562 9.39656 13.0419 9.39015 13.0369 9.37774L10.7005 3.75979C10.6849 3.72196 10.7257 3.68538 10.762 3.705Z"
|
||||
fill={`url(#${id}-paint2_linear)`}
|
||||
/>
|
||||
<path
|
||||
d="M10.7145 3.79041L17.8305 20.7659"
|
||||
stroke="#513C9F"
|
||||
strokeWidth="0.17284"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.44531 11.0476C6.44531 11.0476 10.375 10.3422 14.0686 9.06804C17.7623 7.7939 20.5122 6.23373 20.5122 6.23373"
|
||||
stroke="#513C9F"
|
||||
strokeWidth="0.17284"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.6914 5.93518L9.05534 14.7068M9.05534 14.7068H15.3203M9.05534 14.7068L0.15625 26.3646"
|
||||
stroke="#ABABAB"
|
||||
strokeWidth="0.302474"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.02539 23.9646L4.85806 24.1287C5.46272 25.7287 6.70381 26.4276 8.18528 26.4276C11.8147 26.4276 10.707 22.3227 12.8103 22.3227C14.3358 22.3227 13.7155 25.6498 16.9992 25.6498C19.0029 25.6498 19.2028 23.631 18.8607 22.7625C18.8589 22.7572 18.8568 22.7524 18.8538 22.7476L18.3166 21.9253C18.2817 21.8706 18.1968 21.8912 18.1907 21.9562L18.0908 22.9529C18.0838 23.0222 18.0857 23.0913 18.0936 23.1605C18.1764 23.8491 18.2291 25.5202 16.9992 25.5202C15.7015 25.5202 15.3895 22.2362 12.8103 22.2362C9.78393 22.2362 10.1726 26.298 8.31487 26.298C7.08938 26.298 6.15447 24.9153 6.02539 23.9646Z"
|
||||
fill={`url(#${id}-paint3_linear)`}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`${id}-paint0_linear`}
|
||||
x1="15.5372"
|
||||
y1="5.0278"
|
||||
x2="12.0802"
|
||||
y2="14.534"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6430AB" />
|
||||
<stop offset="1" stopColor="#AA89D8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={`${id}-paint1_linear`}
|
||||
x1="12.8583"
|
||||
y1="10.3858"
|
||||
x2="8.40764"
|
||||
y2="18.9846"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#005DBB" />
|
||||
<stop offset="1" stopColor="#3D92E8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={`${id}-paint2_linear`}
|
||||
x1="14.8452"
|
||||
y1="5.02774"
|
||||
x2="13.5047"
|
||||
y2="9.21911"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#1B70C4" />
|
||||
<stop offset="1" stopColor="#54A4F2" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={`${id}-paint3_linear`}
|
||||
x1="4.85806"
|
||||
y1="24.2455"
|
||||
x2="18.9963"
|
||||
y2="24.2455"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#4497EA" />
|
||||
<stop offset="0.254755" stopColor="#1463B2" />
|
||||
<stop offset="0.498725" stopColor="#0A437D" />
|
||||
<stop offset="0.666667" stopColor="#2476C8" />
|
||||
<stop offset="0.972542" stopColor="#0C549A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopilotChatInspectorButton({
|
||||
title,
|
||||
className,
|
||||
onClick,
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const config = useCopilotChatConfiguration();
|
||||
const labels = config?.labels ?? CopilotChatDefaultLabels;
|
||||
const { isInspectorEnabled } = useCopilotKitInspector();
|
||||
const hidden = useInspectorShortcutsHidden();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dark, setDark] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const openedByHover = useRef(false);
|
||||
const primaryLabel = title || labels.assistantMessageToolbarInspectorLabel;
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
};
|
||||
useEffect(() => cancelClose, []);
|
||||
|
||||
const changeOpen = (next: boolean) => {
|
||||
cancelClose();
|
||||
if (next) setDark(!!trigger.current?.closest(".dark"));
|
||||
setOpen(next);
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => {
|
||||
// Keep the panel available while the keyboard is interacting with it.
|
||||
if (openedByHover.current) setOpen(false);
|
||||
}, 180);
|
||||
};
|
||||
|
||||
if (!isInspectorEnabled || hidden) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={changeOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
{...props}
|
||||
ref={trigger}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
variant="assistantMessageToolbarButton"
|
||||
size="icon"
|
||||
data-testid="copilot-inspector-button"
|
||||
aria-label={`${labels.assistantMessageToolbarInspectorTitle} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel.toLowerCase()})`}
|
||||
className={twMerge("cpk:size-8 cpk:p-1.5", className)}
|
||||
onPointerEnter={(event) => {
|
||||
onPointerEnter?.(event);
|
||||
if (!disabled && event.pointerType !== "touch") {
|
||||
openedByHover.current = true;
|
||||
changeOpen(true);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
onPointerLeave?.(event);
|
||||
scheduleClose();
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
onPointerDown?.(event);
|
||||
// Clicking inspects the message instead of toggling the menu.
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
changeOpen(false);
|
||||
onClick?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
openedByHover.current = false;
|
||||
onKeyDown?.(event);
|
||||
if (
|
||||
!event.defaultPrevented &&
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Wrench
|
||||
aria-hidden="true"
|
||||
className="cpk:size-4"
|
||||
data-testid="copilot-inspector-icon"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className={twMerge("cpk:w-64 cpk:p-1 cpk:text-sm", dark && "dark")}
|
||||
onPointerEnter={cancelClose}
|
||||
onPointerLeave={scheduleClose}
|
||||
onKeyDown={() => {
|
||||
openedByHover.current = false;
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (openedByHover.current) event.preventDefault();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (openedByHover.current) event.preventDefault();
|
||||
}}
|
||||
onFocusOutside={() => changeOpen(false)}
|
||||
>
|
||||
<DropdownMenuLabel className="cpk:flex cpk:items-center cpk:justify-between cpk:gap-3 cpk:px-2 cpk:py-2">
|
||||
<span className="cpk:text-xs cpk:font-medium">
|
||||
{labels.assistantMessageToolbarInspectorTitle}
|
||||
</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="cpk:cursor-help cpk:rounded cpk:bg-muted cpk:px-1.5 cpk:py-0.5 cpk:text-[10px] cpk:font-normal cpk:text-muted-foreground"
|
||||
>
|
||||
{labels.assistantMessageToolbarInspectorLocalOnlyLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className={twMerge("cpk:max-w-48 cpk:text-wrap", dark && "dark")}
|
||||
onPointerEnter={cancelClose}
|
||||
onPointerLeave={scheduleClose}
|
||||
>
|
||||
{labels.assistantMessageToolbarInspectorLocalOnlyDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="cpk:w-full cpk:cursor-pointer cpk:items-start cpk:text-left"
|
||||
>
|
||||
<InspectorKiteIcon className="cpk:mt-0.5" />
|
||||
<span>
|
||||
<span className="cpk:block">{primaryLabel}</span>
|
||||
<span className="cpk:block cpk:text-xs cpk:text-muted-foreground">
|
||||
{labels.assistantMessageToolbarInspectorDescription}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={hideShortcuts}
|
||||
className="cpk:cursor-pointer cpk:items-start"
|
||||
>
|
||||
<EyeOff aria-hidden="true" className="cpk:mt-0.5" />
|
||||
<span>
|
||||
<span className="cpk:block">
|
||||
{labels.assistantMessageToolbarInspectorHideLabel}
|
||||
</span>
|
||||
<span className="cpk:block cpk:text-xs cpk:text-muted-foreground">
|
||||
{labels.assistantMessageToolbarInspectorHideDescription}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from "react";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopilotChat } from "../CopilotChat";
|
||||
import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage";
|
||||
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
|
||||
import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
|
||||
import { stubWindowLocation } from "../../../../v1-deprecated/test-helpers/stub-window-location";
|
||||
|
||||
function InspectorMessage() {
|
||||
return (
|
||||
<CopilotChatAssistantMessage
|
||||
message={{ id: "message", role: "assistant", content: "Inspect me" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TestChat({
|
||||
providerPreference,
|
||||
chatPreference,
|
||||
sibling = false,
|
||||
}: {
|
||||
providerPreference?: boolean;
|
||||
chatPreference?: boolean;
|
||||
sibling?: boolean;
|
||||
}) {
|
||||
const [agents] = useState(() => ({ default: new MockStepwiseAgent() }));
|
||||
return (
|
||||
<CopilotKitProvider
|
||||
agents__unsafe_dev_only={agents}
|
||||
enableInspector={providerPreference}
|
||||
>
|
||||
<div data-testid="chat">
|
||||
<CopilotChat
|
||||
inspectorTools={chatPreference}
|
||||
welcomeScreen={false}
|
||||
children={InspectorMessage}
|
||||
/>
|
||||
</div>
|
||||
{sibling && (
|
||||
<div data-testid="sibling">
|
||||
<CopilotChat welcomeScreen={false} children={InspectorMessage} />
|
||||
</div>
|
||||
)}
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("CopilotChat inspectorTools", () => {
|
||||
it.each([
|
||||
[undefined, undefined, true],
|
||||
[undefined, true, true],
|
||||
[undefined, false, false],
|
||||
[true, undefined, true],
|
||||
[true, true, true],
|
||||
[true, false, true],
|
||||
[false, undefined, false],
|
||||
[false, true, false],
|
||||
[false, false, false],
|
||||
])(
|
||||
"provider=%s, chat=%s produces visible=%s",
|
||||
async (providerPreference, chatPreference, visible) => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
render(
|
||||
<TestChat
|
||||
providerPreference={providerPreference}
|
||||
chatPreference={chatPreference}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(!!screen.queryByTestId("copilot-inspector-button")).toBe(visible);
|
||||
},
|
||||
);
|
||||
|
||||
it("updates chat preferences without affecting a sibling or the shared Inspector", async () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const { rerender } = render(<TestChat chatPreference={false} sibling />);
|
||||
await act(async () => {});
|
||||
expect(
|
||||
within(screen.getByTestId("chat")).queryByTestId(
|
||||
"copilot-inspector-button",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId("sibling")).getByTestId(
|
||||
"copilot-inspector-button",
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(document.querySelector("cpk-web-inspector")).not.toBeNull();
|
||||
|
||||
rerender(<TestChat chatPreference={true} sibling />);
|
||||
expect(screen.getAllByTestId("copilot-inspector-button")).toHaveLength(2);
|
||||
rerender(
|
||||
<TestChat providerPreference={false} chatPreference={true} sibling />,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId("copilot-inspector-button")).toBeNull();
|
||||
rerender(
|
||||
<TestChat providerPreference={true} chatPreference={false} sibling />,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getAllByTestId("copilot-inspector-button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["production", "http://localhost:3000"],
|
||||
["development", "https://preview.example.com"],
|
||||
])("cannot enable shortcuts in %s at %s", async (environment, url) => {
|
||||
vi.stubEnv("NODE_ENV", environment);
|
||||
const restore = stubWindowLocation(url);
|
||||
try {
|
||||
render(<TestChat providerPreference={true} chatPreference={true} />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId("copilot-inspector-button")).toBeNull();
|
||||
expect(document.querySelector("cpk-web-inspector")).toBeNull();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("respects Inspector dismissal and restoration even when both props are true", async () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
render(<TestChat providerPreference={true} chatPreference={true} />);
|
||||
await act(async () => {
|
||||
await vi.dynamicImportSettled();
|
||||
});
|
||||
const inspector = document.querySelector("cpk-web-inspector")!;
|
||||
expect(screen.getByTestId("copilot-inspector-button")).toBeDefined();
|
||||
for (const visible of [false, true]) {
|
||||
act(() => {
|
||||
inspector.dispatchEvent(
|
||||
new CustomEvent("cpk-inspector-visibility-change", {
|
||||
detail: { visible },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(!!screen.queryByTestId("copilot-inspector-button")).toBe(visible);
|
||||
}
|
||||
});
|
||||
});
|
||||
+206
-11
@@ -98,7 +98,7 @@ describe("CopilotChatAssistantMessage", () => {
|
||||
expect(screen.queryByRole("button", { name: /read aloud/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /regenerate/i })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /view in inspector/i }),
|
||||
screen.queryByRole("button", { name: /copilotkit inspector/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -114,28 +114,31 @@ describe("CopilotChatAssistantMessage", () => {
|
||||
);
|
||||
|
||||
const inspectorButton = screen.getByRole("button", {
|
||||
name: "View in Inspector (local only)",
|
||||
name: "CopilotKit Inspector (local only)",
|
||||
});
|
||||
const inspectorIcon = screen.getByTestId("copilot-inspector-icon");
|
||||
|
||||
expect(inspectorIcon.querySelectorAll("linearGradient")).toHaveLength(4);
|
||||
expect(inspectorButton.textContent).toContain("View in Inspector");
|
||||
expect(inspectorButton.textContent).toContain("(local only)");
|
||||
expect(inspectorIcon.classList.contains("lucide-wrench")).toBe(true);
|
||||
expect(inspectorButton.textContent).toBe("");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /save as snippet/i }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(inspectorButton);
|
||||
fireEvent.pointerEnter(inspectorButton, { pointerType: "mouse" });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
"View this message in the Inspector to get more information. This button and the inspector only display during local development (localhost, dev env).",
|
||||
),
|
||||
screen.getByRole("menuitem", {
|
||||
name: "View in Inspector Open this message in the Inspector",
|
||||
}),
|
||||
).toBeDefined(),
|
||||
);
|
||||
|
||||
fireEvent.click(inspectorButton);
|
||||
fireEvent.click(
|
||||
screen.getByRole("menuitem", {
|
||||
name: "View in Inspector Open this message in the Inspector",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(openInspector).toHaveBeenCalledWith({
|
||||
messageId: basicMessage.id,
|
||||
@@ -144,6 +147,164 @@ describe("CopilotChatAssistantMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["click", "hover then click", "Enter", " "])(
|
||||
"opens Inspector directly on %s",
|
||||
async (interaction) => {
|
||||
const openInspector = vi.fn();
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
value={{ isInspectorEnabled: true, openInspector }}
|
||||
>
|
||||
<CopilotChatAssistantMessage message={basicMessage} />
|
||||
</CopilotKitInspectorContextProvider>,
|
||||
);
|
||||
const trigger = screen.getByTestId("copilot-inspector-button");
|
||||
if (interaction === "hover then click") {
|
||||
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
}
|
||||
if (interaction.includes("click")) {
|
||||
fireEvent.pointerDown(trigger, { button: 0 });
|
||||
if (interaction === "click") {
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
}
|
||||
fireEvent.click(trigger);
|
||||
} else {
|
||||
fireEvent.keyDown(trigger, { key: interaction });
|
||||
}
|
||||
expect(openInspector).toHaveBeenCalledExactlyOnceWith({
|
||||
messageId: basicMessage.id,
|
||||
threadId: TEST_THREAD_ID,
|
||||
agentId: "default",
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["hover", "keyboard"])(
|
||||
"preserves the appropriate focus when opening by %s",
|
||||
async (interaction) => {
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
value={{ isInspectorEnabled: true, openInspector: vi.fn() }}
|
||||
>
|
||||
<textarea aria-label="Chat input" />
|
||||
<CopilotChatAssistantMessage message={basicMessage} />
|
||||
</CopilotKitInspectorContextProvider>,
|
||||
);
|
||||
const input = screen.getByRole("textbox", { name: "Chat input" });
|
||||
input.focus();
|
||||
const trigger = screen.getByTestId("copilot-inspector-button");
|
||||
if (interaction === "hover") {
|
||||
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
|
||||
} else {
|
||||
trigger.focus();
|
||||
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
||||
}
|
||||
const menu = await screen.findByRole("menu");
|
||||
if (interaction === "hover") {
|
||||
expect(document.activeElement).toBe(input);
|
||||
} else {
|
||||
await waitFor(() =>
|
||||
expect(menu.contains(document.activeElement)).toBe(true),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("dismisses the hover menu when the pointer leaves", async () => {
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
value={{ isInspectorEnabled: true, openInspector: vi.fn() }}
|
||||
>
|
||||
<CopilotChatAssistantMessage message={basicMessage} />
|
||||
</CopilotKitInspectorContextProvider>,
|
||||
);
|
||||
const trigger = screen.getByTestId("copilot-inspector-button");
|
||||
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
|
||||
const menu = await screen.findByRole("menu");
|
||||
fireEvent.pointerLeave(trigger, { pointerType: "mouse" });
|
||||
fireEvent.pointerEnter(menu, { pointerType: "mouse" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
expect(screen.getByRole("menu")).toBeDefined();
|
||||
fireEvent.pointerLeave(menu, { pointerType: "mouse" });
|
||||
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
||||
});
|
||||
|
||||
it("does not expose a custom Inspector slot when Inspector is disabled", () => {
|
||||
renderWithProvider(
|
||||
<CopilotChatAssistantMessage
|
||||
message={basicMessage}
|
||||
inspectorButton={() => <button>Custom inspect</button>}
|
||||
>
|
||||
{({ inspectorButton }) => inspectorButton}
|
||||
</CopilotChatAssistantMessage>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Custom inspect" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces the default Inspector button with a custom slot component", () => {
|
||||
const openInspector = vi.fn();
|
||||
const CustomInspectorButton = ({
|
||||
onClick,
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button onClick={onClick}>Custom inspect</button>
|
||||
);
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
value={{ isInspectorEnabled: true, openInspector }}
|
||||
>
|
||||
<CopilotChatAssistantMessage
|
||||
message={basicMessage}
|
||||
inspectorButton={CustomInspectorButton}
|
||||
/>
|
||||
</CopilotKitInspectorContextProvider>,
|
||||
);
|
||||
expect(screen.queryByTestId("copilot-inspector-button")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Custom inspect" }));
|
||||
expect(openInspector).toHaveBeenCalledExactlyOnceWith({
|
||||
messageId: basicMessage.id,
|
||||
threadId: TEST_THREAD_ID,
|
||||
agentId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets Inspector slot props override the default click action", () => {
|
||||
const openInspector = vi.fn();
|
||||
const customClick = vi.fn();
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
value={{ isInspectorEnabled: true, openInspector }}
|
||||
>
|
||||
<CopilotChatAssistantMessage
|
||||
message={basicMessage}
|
||||
inspectorButton={{ onClick: customClick }}
|
||||
/>
|
||||
</CopilotKitInspectorContextProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("copilot-inspector-button"));
|
||||
expect(customClick).toHaveBeenCalledTimes(1);
|
||||
expect(openInspector).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a custom toolbar replace all default message actions", () => {
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
value={{ isInspectorEnabled: true, openInspector: vi.fn() }}
|
||||
>
|
||||
<CopilotChatAssistantMessage
|
||||
message={basicMessage}
|
||||
toolbar={() => <button>My action</button>}
|
||||
/>
|
||||
</CopilotKitInspectorContextProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "My action" })).toBeDefined();
|
||||
expect(screen.queryByTestId("copilot-inspector-button")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /copy/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves props added to the bound Inspector button", () => {
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
@@ -165,7 +326,7 @@ describe("CopilotChatAssistantMessage", () => {
|
||||
);
|
||||
|
||||
const inspectorButton = screen.getByRole("button", {
|
||||
name: "View in Inspector (local only)",
|
||||
name: "CopilotKit Inspector (local only)",
|
||||
});
|
||||
expect(inspectorButton.className).toContain("custom-inspector-button");
|
||||
});
|
||||
@@ -772,3 +933,37 @@ describe("CopilotChatAssistantMessage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// This preference lasts for the document's lifetime, so exercise hiding last.
|
||||
it("hides every message shortcut until reload without disabling Inspector", async () => {
|
||||
const openInspector = vi.fn();
|
||||
const messages = ["first", "second"].map((id) => ({
|
||||
id,
|
||||
role: "assistant" as const,
|
||||
content: id,
|
||||
}));
|
||||
const renderMessages = () =>
|
||||
renderWithProvider(
|
||||
<CopilotKitInspectorContextProvider
|
||||
value={{ isInspectorEnabled: true, openInspector }}
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<CopilotChatAssistantMessage key={message.id} message={message} />
|
||||
))}
|
||||
</CopilotKitInspectorContextProvider>,
|
||||
);
|
||||
const view = renderMessages();
|
||||
expect(screen.getAllByTestId("copilot-inspector-button")).toHaveLength(2);
|
||||
fireEvent.pointerEnter(screen.getAllByTestId("copilot-inspector-button")[0], {
|
||||
pointerType: "mouse",
|
||||
});
|
||||
fireEvent.click(
|
||||
await screen.findByRole("menuitem", { name: /hide this icon/i }),
|
||||
);
|
||||
expect(screen.queryByTestId("copilot-inspector-button")).toBeNull();
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
||||
expect(openInspector).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
renderMessages();
|
||||
expect(screen.queryByTestId("copilot-inspector-button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -37,7 +37,10 @@ function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & {
|
||||
// Radix 2.1 forwards this to its focus scope but omits it from public types.
|
||||
onOpenAutoFocus?: (event: Event) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
|
||||
@@ -26,7 +26,14 @@ export const CopilotChatDefaultLabels = {
|
||||
assistantMessageToolbarCopyCodeCopiedLabel: "Copied",
|
||||
assistantMessageToolbarCopyMessageLabel: "Copy",
|
||||
assistantMessageToolbarInspectorLabel: "View in Inspector",
|
||||
assistantMessageToolbarInspectorLocalOnlyLabel: "Development Only",
|
||||
assistantMessageToolbarInspectorDescription:
|
||||
"Open this message in the Inspector",
|
||||
assistantMessageToolbarInspectorLocalOnlyLabel: "Local only",
|
||||
assistantMessageToolbarInspectorLocalOnlyDescription:
|
||||
"Only visible on localhost or loopback hosts in development. Never shown in production.",
|
||||
assistantMessageToolbarInspectorTitle: "CopilotKit Inspector",
|
||||
assistantMessageToolbarInspectorHideLabel: "Hide this icon",
|
||||
assistantMessageToolbarInspectorHideDescription: "Until you reload this page",
|
||||
assistantMessageToolbarThumbsUpLabel: "Good response",
|
||||
assistantMessageToolbarThumbsDownLabel: "Bad response",
|
||||
assistantMessageToolbarReadAloudLabel: "Read aloud",
|
||||
|
||||
@@ -200,8 +200,11 @@ export interface CopilotKitProviderProps {
|
||||
showDevConsole?: boolean | "auto";
|
||||
/**
|
||||
* Disable the CopilotKit Inspector in development.
|
||||
* The Inspector is enabled by default in development browser builds and is
|
||||
* always disabled in production and during server rendering.
|
||||
* The Inspector is enabled by default in development browser builds on
|
||||
* localhost/loopback. It is always disabled on remote hosts, in production,
|
||||
* and during server rendering. Temporary Inspector hides also hide its
|
||||
* message shortcuts.
|
||||
* An explicit value takes priority over CopilotChat's inspectorTools prop.
|
||||
*/
|
||||
enableInspector?: boolean;
|
||||
/**
|
||||
@@ -321,9 +324,10 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
|
||||
debug,
|
||||
}) => {
|
||||
// Keep the server render and the first client render identical. The
|
||||
// Inspector is browser-only, so resolve its development policy after
|
||||
// hydration instead of branching on `window` during render.
|
||||
// Inspector only runs in local development. Resolve its host and build
|
||||
// policy after hydration instead of branching on `window` during render.
|
||||
const [shouldRenderInspector, setShouldRenderInspector] = useState(false);
|
||||
const [inspectorVisible, setInspectorVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShouldRenderInspector(
|
||||
@@ -331,7 +335,8 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
|
||||
enableInspector,
|
||||
isBrowser: true,
|
||||
isDevelopment: process.env.NODE_ENV === "development",
|
||||
}),
|
||||
}) &&
|
||||
["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname),
|
||||
);
|
||||
}, [enableInspector]);
|
||||
|
||||
@@ -366,10 +371,16 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
|
||||
|
||||
const inspectorContextValue = useMemo(
|
||||
() => ({
|
||||
isInspectorEnabled: shouldRenderInspector,
|
||||
providerEnableInspector: enableInspector,
|
||||
isInspectorEnabled: shouldRenderInspector && inspectorVisible,
|
||||
openInspector: requestInspectorOpen,
|
||||
}),
|
||||
[shouldRenderInspector, requestInspectorOpen],
|
||||
[
|
||||
enableInspector,
|
||||
shouldRenderInspector,
|
||||
inspectorVisible,
|
||||
requestInspectorOpen,
|
||||
],
|
||||
);
|
||||
|
||||
// Normalize array props to stable references with clear dev warnings
|
||||
@@ -1074,6 +1085,7 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
|
||||
<CopilotKitInspector
|
||||
core={copilotkit}
|
||||
openRequest={inspectorOpenRequest}
|
||||
onVisibilityChange={setInspectorVisible}
|
||||
/>
|
||||
) : null}
|
||||
</CopilotKitInspectorContextProvider>
|
||||
|
||||
+58
-7
@@ -30,22 +30,73 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("CopilotKitProvider development Inspector action", () => {
|
||||
it("renders in development on any browser host", async () => {
|
||||
it.each([
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://[::1]:3000",
|
||||
])("renders in local development at %s", async (url) => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const restoreLocation = stubWindowLocation("http://192.168.1.25:3000");
|
||||
|
||||
const restoreLocation = stubWindowLocation(url);
|
||||
try {
|
||||
renderAssistantMessage();
|
||||
await act(async () => {});
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /view in inspector/i }),
|
||||
screen.getByRole("button", { name: /copilotkit inspector/i }),
|
||||
).toBeDefined();
|
||||
} finally {
|
||||
restoreLocation();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://192.168.1.25:3000",
|
||||
"https://preview.example.com",
|
||||
"https://localhost.example.com",
|
||||
])(
|
||||
"never renders on remote host %s, even explicitly enabled",
|
||||
async (url) => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const restoreLocation = stubWindowLocation(url);
|
||||
try {
|
||||
renderAssistantMessage(true);
|
||||
await act(async () => {});
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /copilotkit inspector/i }),
|
||||
).toBeNull();
|
||||
expect(document.querySelector("cpk-web-inspector")).toBeNull();
|
||||
} finally {
|
||||
restoreLocation();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("follows Inspector dismissal and expiry without unmounting the Inspector", async () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
renderAssistantMessage();
|
||||
await act(async () => {
|
||||
await vi.dynamicImportSettled();
|
||||
});
|
||||
const inspector = document.querySelector("cpk-web-inspector")!;
|
||||
expect(screen.getByTestId("copilot-inspector-button")).toBeDefined();
|
||||
act(() => {
|
||||
inspector.dispatchEvent(
|
||||
new CustomEvent("cpk-inspector-visibility-change", {
|
||||
detail: { visible: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId("copilot-inspector-button")).toBeNull();
|
||||
expect(inspector.isConnected).toBe(true);
|
||||
act(() => {
|
||||
inspector.dispatchEvent(
|
||||
new CustomEvent("cpk-inspector-visibility-change", {
|
||||
detail: { visible: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId("copilot-inspector-button")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render in production, even when explicitly enabled", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
@@ -53,7 +104,7 @@ describe("CopilotKitProvider development Inspector action", () => {
|
||||
await act(async () => {});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /view in inspector/i }),
|
||||
screen.queryByRole("button", { name: /copilotkit inspector/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -64,7 +115,7 @@ describe("CopilotKitProvider development Inspector action", () => {
|
||||
await act(async () => {});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /view in inspector/i }),
|
||||
screen.queryByRole("button", { name: /copilotkit inspector/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,8 +76,8 @@ describe("CopilotKitProvider", () => {
|
||||
vi.mocked(defineWebInspector).mockClear();
|
||||
});
|
||||
|
||||
it("renders by default on any development host and passes the provider core before connection", async () => {
|
||||
const restoreLocation = stubWindowLocation("http://192.168.1.25:3000");
|
||||
it("renders by default on a local development host and passes the provider core before connection", async () => {
|
||||
const restoreLocation = stubWindowLocation("http://localhost:3000");
|
||||
let providerCore: ReturnType<typeof useCopilotKit>["copilotkit"] | null =
|
||||
null;
|
||||
const Probe = () => {
|
||||
|
||||
@@ -1048,6 +1048,15 @@ test("dismissing the HUD notification keeps the one-day hide action", async () =
|
||||
|
||||
test("the notification HUD hides the Inspector for a day across localhost ports", async () => {
|
||||
const context = await setup();
|
||||
const visibilityChanges: boolean[] = [];
|
||||
context.inspector.addEventListener(
|
||||
"cpk-inspector-visibility-change",
|
||||
(event) => {
|
||||
visibilityChanges.push(
|
||||
(event as CustomEvent<{ visible: boolean }>).detail.visible,
|
||||
);
|
||||
},
|
||||
);
|
||||
await openHud(context.inspector);
|
||||
|
||||
const action = requireElement(
|
||||
@@ -1092,12 +1101,21 @@ test("the notification HUD hides the Inspector for a day across localhost ports"
|
||||
const clickedAt = Date.now();
|
||||
await click(context.inspector, action);
|
||||
expect(root(context.inspector).querySelector(".console-button")).toBeNull();
|
||||
expect(visibilityChanges.at(-1)).toBe(false);
|
||||
expect(root(context.inspector).querySelector(".inspector-window")).toBeNull();
|
||||
expect(dismissalDeadline()).toBeGreaterThanOrEqual(clickedAt + DAY_MS);
|
||||
expect(dismissalDeadline()).toBeLessThanOrEqual(Date.now() + DAY_MS);
|
||||
|
||||
context.changePort();
|
||||
const dispatch = vi.spyOn(WebInspectorElement.prototype, "dispatchEvent");
|
||||
const otherPort = await context.remount();
|
||||
expect(
|
||||
dispatch.mock.calls.some(
|
||||
([event]) =>
|
||||
event.type === "cpk-inspector-visibility-change" &&
|
||||
(event as CustomEvent<{ visible: boolean }>).detail.visible === false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(root(otherPort).querySelector(".console-button")).toBeNull();
|
||||
expect(root(otherPort).querySelector(".inspector-window")).toBeNull();
|
||||
});
|
||||
@@ -1124,6 +1142,15 @@ test("a host dismissal fully tears down an open docked Inspector", async () => {
|
||||
|
||||
test("the launcher returns automatically when a dismissal expires", async () => {
|
||||
const context = await setup();
|
||||
const visibilityChanges: boolean[] = [];
|
||||
context.inspector.addEventListener(
|
||||
"cpk-inspector-visibility-change",
|
||||
(event) => {
|
||||
visibilityChanges.push(
|
||||
(event as CustomEvent<{ visible: boolean }>).detail.visible,
|
||||
);
|
||||
},
|
||||
);
|
||||
await openHud(context.inspector);
|
||||
const action = requireElement(
|
||||
root(context.inspector).querySelector<HTMLButtonElement>(
|
||||
@@ -1136,6 +1163,7 @@ test("the launcher returns automatically when a dismissal expires", async () =>
|
||||
action.click();
|
||||
await context.inspector.updateComplete;
|
||||
expect(root(context.inspector).querySelector(".console-button")).toBeNull();
|
||||
expect(visibilityChanges.at(-1)).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DAY_MS + 50);
|
||||
await context.inspector.updateComplete;
|
||||
@@ -1143,10 +1171,20 @@ test("the launcher returns automatically when a dismissal expires", async () =>
|
||||
expect(
|
||||
root(context.inspector).querySelector(".console-button"),
|
||||
).not.toBeNull();
|
||||
expect(visibilityChanges).toEqual([false, true]);
|
||||
});
|
||||
|
||||
test("Settings offers the longer one-week dismissal", async () => {
|
||||
const context = await setup({ persistedMenu: "threads" });
|
||||
const visibilityChanges: boolean[] = [];
|
||||
context.inspector.addEventListener(
|
||||
"cpk-inspector-visibility-change",
|
||||
(event) => {
|
||||
visibilityChanges.push(
|
||||
(event as CustomEvent<{ visible: boolean }>).detail.visible,
|
||||
);
|
||||
},
|
||||
);
|
||||
await click(context.inspector, launcherButton(context.inspector));
|
||||
await click(
|
||||
context.inspector,
|
||||
@@ -1173,6 +1211,7 @@ test("Settings offers the longer one-week dismissal", async () => {
|
||||
const clickedAt = Date.now();
|
||||
await click(context.inspector, action);
|
||||
expect(root(context.inspector).querySelector(".console-button")).toBeNull();
|
||||
expect(visibilityChanges.at(-1)).toBe(false);
|
||||
expect(root(context.inspector).querySelector(".inspector-window")).toBeNull();
|
||||
expect(dismissalDeadline()).toBeGreaterThanOrEqual(clickedAt + WEEK_MS);
|
||||
expect(dismissalDeadline()).toBeLessThanOrEqual(Date.now() + WEEK_MS);
|
||||
|
||||
@@ -6754,6 +6754,7 @@ export class WebInspectorElement extends LitElement {
|
||||
private launcherHudIntroEndTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Host-wide deadline that suppresses both the Inspector and its launcher. */
|
||||
private inspectorDismissedUntil: number | null = null;
|
||||
private lastReportedInspectorVisibility: boolean | null = null;
|
||||
private inspectorDismissalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/**
|
||||
* Leaf a HUD row asked for. Consumed by `openInspector` so a red dot on
|
||||
@@ -11532,6 +11533,17 @@ export class WebInspectorElement extends LitElement {
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
// Host message shortcuts follow the actual Inspector, including persisted
|
||||
// dismissals and their expiry. Closing the panel still leaves it available.
|
||||
const visible = !this.isInspectorDismissed;
|
||||
if (visible !== this.lastReportedInspectorVisibility) {
|
||||
this.lastReportedInspectorVisibility = visible;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("cpk-inspector-visibility-change", {
|
||||
detail: { visible },
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.syncInspectorPortal();
|
||||
this.syncThreadsExampleOverviewVideo();
|
||||
this.maybeTrackInspectorMetadataViews();
|
||||
|
||||
@@ -43,6 +43,16 @@ import "@copilotkit/react-core/v2/styles.css";
|
||||
object to extend the default.
|
||||
</PropertyReference>
|
||||
|
||||
<PropertyReference name="inspectorTools" type="boolean" default="true">
|
||||
Controls Inspector message shortcuts for this chat. An explicit
|
||||
`enableInspector` value on the `CopilotKit` provider takes priority: provider
|
||||
`true` overrides chat `false`, and provider `false` overrides chat `true`.
|
||||
When the provider omits the prop, `inspectorTools={false}` hides this chat's
|
||||
shortcuts. Shortcuts only appear on localhost/loopback in development and
|
||||
remain hidden while Inspector is temporarily dismissed. This prop does not
|
||||
control the shared Inspector launcher.
|
||||
</PropertyReference>
|
||||
|
||||
<PropertyReference name="onError" type="(event: { error: Error; code: CopilotKitCoreErrorCode; context: Record<string, any> }) => void | Promise<void>">
|
||||
Error handler scoped to this chat's agent. Fires in addition to the provider-level `onError` (does not suppress it). Only receives errors whose `context.agentId` matches this chat's agent, plus errors without an `agentId` context.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user