diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx
index 7d01c5c936..a3f8095bf3 100644
--- a/packages/mantine/src/components.tsx
+++ b/packages/mantine/src/components.tsx
@@ -82,7 +82,7 @@ export const components: Components = {
Group: BadgeGroup,
},
Form: {
- Root: (props) =>
{props.children}
,
+ Root: (props) => <>{props.children}>,
TextInput: TextInput,
},
Menu: {
diff --git a/packages/mantine/src/form/TextInput.tsx b/packages/mantine/src/form/TextInput.tsx
index b781073588..265ed819af 100644
--- a/packages/mantine/src/form/TextInput.tsx
+++ b/packages/mantine/src/form/TextInput.tsx
@@ -33,7 +33,7 @@ export const TextInput = forwardRef<
size={"xs"}
className={mergeCSSClasses(
className || "",
- variant === "large" ? "bn-mt-input-large" : ""
+ variant === "large" ? "bn-mt-input-large" : "",
)}
ref={ref}
name={name}
diff --git a/packages/mantine/src/style.css b/packages/mantine/src/style.css
index e2a2273aa4..9f665e8f67 100644
--- a/packages/mantine/src/style.css
+++ b/packages/mantine/src/style.css
@@ -766,26 +766,73 @@
}
/* Combobox styling */
-.bn-mantine .bn-combobox-input,
+.bn-mantine .bn-combobox-input-wrapper,
.bn-mantine .bn-combobox-items:not(:empty) {
background-color: var(--bn-colors-menu-background);
border: var(--bn-border);
border-radius: var(--bn-border-radius-medium);
box-shadow: var(--bn-shadow-medium);
color: var(--bn-colors-menu-text);
- gap: 4px;
min-width: 145px;
padding: 2px;
}
-.bn-mantine .bn-combobox-input .bn-combobox-icon,
-.bn-mantine .bn-combobox-input .bn-combobox-right-section {
+.bn-mantine .bn-combobox-input-wrapper,
+.bn-mantine .bn-combobox-input,
+.bn-mantine .bn-combobox-loader,
+.bn-mantine .bn-combobox-left-section,
+.bn-mantine .bn-combobox-right-section {
align-items: center;
display: flex;
justify-content: center;
}
-.bn-mantine .bn-combobox-input .bn-combobox-error {
+.bn-mantine .bn-combobox-input,
+.bn-mantine .bn-combobox-loader {
+ flex: 1;
+ justify-content: flex-start;
+ width: 0;
+}
+
+.bn-mantine .bn-combobox-input > div {
+ width: 100%;
+}
+
+.bn-mantine .bn-combobox-input input {
+ padding: 0;
+}
+
+.bn-mantine .bn-ai-loader {
+ align-items: center;
+ color: var(--bn-colors-side-menu);
+ display: flex;
+ font-family: var(--bn-font-family);
+ font-size: 14px;
+ gap: 4px;
+ height: 52px;
+ justify-content: flex-start;
+ width: 100%;
+}
+
+.bn-mantine .bn-ai-loader-icon {
+ width: fit-content;
+}
+
+.bn-mantine .bn-ai-icon,
+.bn-mantine .bn-ai-stop,
+.bn-mantine .bn-ai-error {
+ align-items: center;
+ color: var(--bn-colors-menu-text);
+ display: flex;
+ justify-content: center;
+ width: 28px;
+}
+
+.bn-mantine .bn-ai-stop {
+ cursor: pointer;
+}
+
+.bn-mantine .bn-ai-error {
color: var(--bn-colors-highlights-red-background);
}
diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx
index 2eb161d828..80486df5b5 100644
--- a/packages/react/src/editor/ComponentsContext.tsx
+++ b/packages/react/src/editor/ComponentsContext.tsx
@@ -262,7 +262,7 @@ export type ComponentProps = {
name: string;
label?: string;
variant?: "default" | "large";
- icon: ReactNode;
+ icon?: ReactNode;
rightSection?: ReactNode;
autoFocus?: boolean;
placeholder?: string;
diff --git a/packages/xl-ai/src/components/AIMenu/AIMenu.tsx b/packages/xl-ai/src/components/AIMenu/AIMenu.tsx
index 3fc4db4782..cb6d6daf79 100644
--- a/packages/xl-ai/src/components/AIMenu/AIMenu.tsx
+++ b/packages/xl-ai/src/components/AIMenu/AIMenu.tsx
@@ -1,7 +1,11 @@
import { BlockNoteEditor } from "@blocknote/core";
import { useBlockNoteEditor, useComponentsContext } from "@blocknote/react";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { RiSparkling2Fill } from "react-icons/ri";
+import {
+ RiErrorWarningFill,
+ RiSparkling2Fill,
+ RiStopCircleFill,
+} from "react-icons/ri";
import { useStore } from "zustand";
import { getAIExtension } from "../../AIExtension.js";
@@ -80,66 +84,55 @@ export const AIMenu = (props: AIMenuProps) => {
}, [aiResponseStatus]);
const placeholder = useMemo(() => {
- if (aiResponseStatus === "thinking") {
- return dict.ai_menu.status.thinking;
- } else if (aiResponseStatus === "ai-writing") {
- return dict.ai_menu.status.editing;
- } else if (aiResponseStatus === "error") {
+ if (aiResponseStatus === "error") {
return dict.ai_menu.status.error;
}
return dict.ai_menu.input_placeholder;
}, [aiResponseStatus, dict]);
- const rightSection = useMemo(() => {
- if (aiResponseStatus === "thinking" || aiResponseStatus === "ai-writing") {
- return (
- // TODO
- ai.abort()}>
+ return (
+
setPrompt(e.target.value),
+ onSubmit: () =>
+ props.onManualPromptSubmit?.(prompt) ||
+ onManualPromptSubmitDefault(prompt),
+ placeholder,
+ }}
+ loader={
+
+
+ {aiResponseStatus === "ai-writing"
+ ? dict.ai_menu.status.editing
+ : dict.ai_menu.status.thinking}
+
- );
- } else if (aiResponseStatus === "error") {
- return (
-
- {/* Taken from Google Material Icons */}
- {/* https://fonts.google.com/icons?selected=Material+Symbols+Rounded:error:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=error&icon.size=24&icon.color=%23e8eaed&icon.set=Material+Symbols&icon.style=Rounded&icon.platform=web */}
-
-
-
-
- );
- }
-
- return undefined;
- }, [Components, aiResponseStatus, ai]);
-
- return (
-
+ leftSection={
+
}
- rightSection={rightSection}
+ rightSection={
+ aiResponseStatus === "thinking" || aiResponseStatus === "ai-writing" ? (
+
+
+
+ ) : aiResponseStatus === "error" ? (
+ ai.abort()}>
+
+
+ ) : undefined
+ }
/>
);
};
diff --git a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx
index b18e423a8a..9244d33731 100644
--- a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx
+++ b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx
@@ -1,5 +1,6 @@
import { filterSuggestionItems, mergeCSSClasses } from "@blocknote/core";
import {
+ ComponentProps,
DefaultReactSuggestionItem,
useComponentsContext,
useSuggestionMenuKeyboardHandler,
@@ -16,48 +17,56 @@ import {
export type PromptSuggestionMenuProps = {
items: DefaultReactSuggestionItem[];
- onManualPromptSubmit: (userPrompt: string) => void;
- promptText?: string;
- onPromptTextChange?: (userPrompt: string) => void;
- icon?: ReactNode;
+ inputProps: Partial<
+ Omit<
+ ComponentProps["Generic"]["Form"]["TextInput"],
+ "name" | "label" | "variant" | "autoFocus" | "autoComplete"
+ >
+ >;
+ // This loader element was added as a prop to mimic Notion's UX for the AI
+ // menu. When the AI is generating, Notion puts a loading indicator right
+ // after the "Thinking" text. While it would be better to do this by just
+ // setting the `placeholder` and `rightSection` props of the loader, text
+ // input width can't be constrained to the size of its content, so we can't
+ // place the loading indicator right after. This prop therefore exists so
+ // that we don't have to show the text input while `isLoading` is `true`, and
+ // can instead render whatever we want.
+ loader?: ReactNode;
+ isLoading?: boolean;
+ leftSection?: ReactNode;
rightSection?: ReactNode;
- placeholder?: string;
- disabled?: boolean;
};
export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => {
// const dict = useAIDictionary();
const Components = useComponentsContext()!;
- const { onManualPromptSubmit, promptText, onPromptTextChange } = props;
+ const { value, onKeyDown, onChange, onSubmit, ...rest } = props.inputProps;
- // Only used internal state when `props.prompText` is undefined (i.e., uncontrolled mode)
+ // Only used internal state when `promptText` is undefined (i.e., uncontrolled mode)
const [internalPromptText, setInternalPromptText] = useState("");
- const promptTextToUse = promptText || internalPromptText;
+ const promptTextToUse = value || internalPromptText;
const handleEnter = useCallback(
- async (event: KeyboardEvent) => {
+ async (event: KeyboardEvent) => {
+ onKeyDown?.(event);
if (event.key === "Enter") {
- // console.log("ENTER", currentEditingPrompt);
- onManualPromptSubmit(promptTextToUse);
+ onSubmit?.();
}
},
- [promptTextToUse, onManualPromptSubmit],
+ [onKeyDown, onSubmit],
);
const handleChange = useCallback(
(event: ChangeEvent) => {
- const newValue = event.currentTarget.value;
- if (onPromptTextChange) {
- onPromptTextChange(newValue);
- }
+ onChange?.(event);
// Only update internal state if it's uncontrolled
- if (promptText === undefined) {
- setInternalPromptText(newValue);
+ if (value === undefined) {
+ setInternalPromptText(event.currentTarget.value);
}
},
- [onPromptTextChange, setInternalPromptText, promptText],
+ [onChange, value],
);
const items: DefaultReactSuggestionItem[] = useMemo(() => {
@@ -68,7 +77,7 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => {
useSuggestionMenuKeyboardHandler(items, (item) => item.onItemClick());
const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
+ (event: KeyboardEvent) => {
// TODO: handle backspace to close
if (event.key === "Enter") {
if (items.length > 0) {
@@ -91,27 +100,38 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => {
return (
-
-
-
+
+ {props.leftSection && (
+
{props.leftSection}
+ )}
+ {!props.isLoading || !props.loader ? (
+
+
+
+
+
+ ) : (
+
{props.loader}
+ )}
+ {props.rightSection && (
+
{props.rightSection}
+ )}
+