Skip to content

feat: AI menu UX #1853

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: feature/ai-abort
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/mantine/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const components: Components = {
Group: BadgeGroup,
},
Form: {
Root: (props) => <div>{props.children}</div>,
Root: (props) => <>{props.children}</>,
TextInput: TextInput,
},
Menu: {
Expand Down
2 changes: 1 addition & 1 deletion packages/mantine/src/form/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
57 changes: 52 additions & 5 deletions packages/mantine/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/editor/ComponentsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export type ComponentProps = {
name: string;
label?: string;
variant?: "default" | "large";
icon: ReactNode;
icon?: ReactNode;
rightSection?: ReactNode;
autoFocus?: boolean;
placeholder?: string;
Expand Down
85 changes: 39 additions & 46 deletions packages/xl-ai/src/components/AIMenu/AIMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
<div onClick={() => ai.abort()}>
return (
<PromptSuggestionMenu
items={items}
inputProps={{
value: prompt,
onChange: (e) => setPrompt(e.target.value),
onSubmit: () =>
props.onManualPromptSubmit?.(prompt) ||
onManualPromptSubmitDefault(prompt),
placeholder,
}}
loader={
<div className={"bn-ai-loader"}>
<span className={"bn-ai-loader-text"}>
{aiResponseStatus === "ai-writing"
? dict.ai_menu.status.editing
: dict.ai_menu.status.thinking}
</span>
<Components.SuggestionMenu.Loader
className={"bn-suggestion-menu-loader bn-combobox-right-section"}
className={"bn-ai-loader-icon bn-suggestion-menu-loader"}
/>
</div>
);
} else if (aiResponseStatus === "error") {
return (
<div className={"bn-combobox-right-section bn-combobox-error"}>
{/* 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 */}
<svg
xmlns="http://www.w3.org/2000/svg"
height="1em"
viewBox="0 -960 960 960"
width="1em"
fill="currentColor"
>
<path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm0-160q17 0 28.5-11.5T520-480v-160q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640v160q0 17 11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
</svg>
</div>
);
}

return undefined;
}, [Components, aiResponseStatus, ai]);

return (
<PromptSuggestionMenu
onManualPromptSubmit={
props.onManualPromptSubmit || onManualPromptSubmitDefault
}
items={items}
promptText={prompt}
onPromptTextChange={setPrompt}
placeholder={placeholder}
disabled={
isLoading={
aiResponseStatus === "thinking" || aiResponseStatus === "ai-writing"
}
icon={
<div className="bn-combobox-icon">
leftSection={
<div className={"bn-ai-icon"}>
<RiSparkling2Fill />
</div>
}
rightSection={rightSection}
rightSection={
aiResponseStatus === "thinking" || aiResponseStatus === "ai-writing" ? (
<div className={"bn-ai-stop"}>
<RiStopCircleFill />
</div>
) : aiResponseStatus === "error" ? (
<div className={"bn-ai-error"} onClick={() => ai.abort()}>
<RiErrorWarningFill />
</div>
) : undefined
}
/>
);
};
100 changes: 60 additions & 40 deletions packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { filterSuggestionItems, mergeCSSClasses } from "@blocknote/core";
import {
ComponentProps,
DefaultReactSuggestionItem,
useComponentsContext,
useSuggestionMenuKeyboardHandler,
Expand All @@ -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<string>("");
const promptTextToUse = promptText || internalPromptText;
const promptTextToUse = value || internalPromptText;

const handleEnter = useCallback(
async (event: KeyboardEvent) => {
async (event: KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.key === "Enter") {
// console.log("ENTER", currentEditingPrompt);
onManualPromptSubmit(promptTextToUse);
onSubmit?.();
}
},
[promptTextToUse, onManualPromptSubmit],
[onKeyDown, onSubmit],
);

const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
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(() => {
Expand All @@ -68,7 +77,7 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => {
useSuggestionMenuKeyboardHandler(items, (item) => item.onItemClick());

const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
(event: KeyboardEvent<HTMLInputElement>) => {
// TODO: handle backspace to close
if (event.key === "Enter") {
if (items.length > 0) {
Expand All @@ -91,27 +100,38 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => {

return (
<div className={"bn-combobox"}>
<Components.Generic.Form.Root>
<Components.Generic.Form.TextInput
// Change the key when disabled change, so that autofocus is retriggered
key={"input-" + props.disabled}
className={"bn-combobox-input"}
name={"ai-prompt"}
variant={"large"}
icon={props.icon}
value={promptTextToUse || ""}
autoFocus={true}
placeholder={props.placeholder}
disabled={props.disabled}
onKeyDown={handleKeyDown}
onChange={handleChange}
autoComplete={"off"}
rightSection={props.rightSection}
/>
</Components.Generic.Form.Root>
<div className={"bn-combobox-input-wrapper"}>
{props.leftSection && (
<div className="bn-combobox-left-section">{props.leftSection}</div>
)}
{!props.isLoading || !props.loader ? (
<div className="bn-combobox-input">
<Components.Generic.Form.Root>
<Components.Generic.Form.TextInput
// Change the key when disabled change, so that autofocus is retriggered
// key={"input-" + props.disabled}
name={"ai-prompt"}
variant={"large"}
value={promptTextToUse || ""}
autoFocus={true}
onKeyDown={handleKeyDown}
onChange={handleChange}
autoComplete={"off"}
{...rest}
/>
</Components.Generic.Form.Root>
</div>
) : (
<div className="bn-combobox-loader">{props.loader}</div>
)}
{props.rightSection && (
<div className="bn-combobox-right-section">{props.rightSection}</div>
)}
</div>
<Components.SuggestionMenu.Root
className={"bn-combobox-items"}
id={"ai-suggestion-menu"}>
id={"ai-suggestion-menu"}
>
{items.map((item, i) => (
<Components.SuggestionMenu.Item
key={item.title}
Expand Down
4 changes: 2 additions & 2 deletions packages/xl-ai/src/i18n/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export const ar: AIDictionary = {
ai_menu: {
input_placeholder: "اسأل الذكاء الاصطناعي أي شيء…",
status: {
thinking: "جاري التفكير",
editing: "جاري التحرير",
thinking: "جاري التفكير",
editing: "جاري التحرير",
error: "عذراً! حدث خطأ ما",
},
actions: {
Expand Down
4 changes: 2 additions & 2 deletions packages/xl-ai/src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export const de: AIDictionary = {
ai_menu: {
input_placeholder: "Frage die KI was auch immer…",
status: {
thinking: "Denke nach",
editing: "Bearbeite",
thinking: "Denke nach",
editing: "Bearbeite",
error: "Ups! Etwas ist schiefgelaufen",
},
actions: {
Expand Down
Loading
Loading