Skip to content

Commit 716b5f9

Browse files
feat: add dedicated chat editor type with slash command support (#2068)
* feat: add slash command to chat input for querying TinyBase data Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * refactor: create dedicated chat editor type for slash commands Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent cd3ecd6 commit 716b5f9

File tree

4 files changed

+153
-12
lines changed

4 files changed

+153
-12
lines changed

apps/desktop/src/components/chat/input.tsx

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { FullscreenIcon, MicIcon, PaperclipIcon, SendIcon } from "lucide-react";
2-
import { useCallback, useEffect, useRef } from "react";
2+
import { useCallback, useEffect, useMemo, useRef } from "react";
33

4-
import type { TiptapEditor } from "@hypr/tiptap/editor";
5-
import Editor from "@hypr/tiptap/editor";
4+
import type { SlashCommandConfig, TiptapEditor } from "@hypr/tiptap/chat";
5+
import ChatEditor from "@hypr/tiptap/chat";
66
import {
77
EMPTY_TIPTAP_DOC,
88
type PlaceholderFunction,
@@ -11,6 +11,7 @@ import { Button } from "@hypr/ui/components/ui/button";
1111
import { cn } from "@hypr/utils";
1212

1313
import { useShell } from "../../contexts/shell";
14+
import * as main from "../../store/tinybase/main";
1415

1516
export function ChatMessageInput({
1617
onSendMessage,
@@ -20,6 +21,7 @@ export function ChatMessageInput({
2021
disabled?: boolean | { disabled: boolean; message?: string };
2122
}) {
2223
const editorRef = useRef<{ editor: TiptapEditor | null }>(null);
24+
const store = main.UI.useStore(main.STORE_ID);
2325

2426
const disabled =
2527
typeof disabledProp === "object" ? disabledProp.disabled : disabledProp;
@@ -59,21 +61,44 @@ export function ChatMessageInput({
5961
console.log("Voice input clicked");
6062
}, []);
6163

64+
const slashCommandConfig: SlashCommandConfig = useMemo(
65+
() => ({
66+
handleSearch: async (query: string) => {
67+
if (!store) {
68+
return [];
69+
}
70+
71+
const results: { id: string; type: string; label: string }[] = [];
72+
const lowerQuery = query.toLowerCase();
73+
74+
store.forEachRow("sessions", (rowId, forEachCell) => {
75+
let title = "";
76+
forEachCell((cellId, cell) => {
77+
if (cellId === "title" && typeof cell === "string") {
78+
title = cell;
79+
}
80+
});
81+
if (title && title.toLowerCase().includes(lowerQuery)) {
82+
results.push({ id: rowId, type: "session", label: title });
83+
}
84+
});
85+
86+
return results.slice(0, 5);
87+
},
88+
}),
89+
[store],
90+
);
91+
6292
return (
6393
<Container>
6494
<div className="flex flex-col p-2">
6595
<div className="flex-1 mb-2">
66-
<Editor
96+
<ChatEditor
6797
ref={editorRef}
6898
editable={!disabled}
6999
initialContent={EMPTY_TIPTAP_DOC}
70100
placeholderComponent={ChatPlaceholder}
71-
mentionConfig={{
72-
trigger: "@",
73-
handleSearch: async () => [
74-
{ id: "123", type: "human", label: "John Doe" },
75-
],
76-
}}
101+
slashCommandConfig={slashCommandConfig}
77102
/>
78103
</div>
79104

packages/tiptap/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"exports": {
77
"./editor": "./src/editor/index.tsx",
8+
"./chat": "./src/chat/index.tsx",
89
"./prompt": "./src/prompt/index.tsx",
910
"./shared": "./src/shared/index.ts",
1011
"./styles.css": "./styles.css"

packages/tiptap/src/chat/index.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
EditorContent,
3+
type JSONContent,
4+
type Editor as TiptapEditor,
5+
useEditor,
6+
} from "@tiptap/react";
7+
import { forwardRef, useEffect, useMemo, useRef } from "react";
8+
9+
import "../../styles.css";
10+
import { mention, type MentionConfig } from "../editor/mention";
11+
import * as shared from "../shared";
12+
import type { PlaceholderFunction } from "../shared/extensions/placeholder";
13+
14+
export type { JSONContent, TiptapEditor };
15+
export type { MentionConfig };
16+
17+
export interface SlashCommandConfig {
18+
handleSearch: (
19+
query: string,
20+
) => Promise<{ id: string; type: string; label: string }[]>;
21+
}
22+
23+
interface ChatEditorProps {
24+
initialContent?: JSONContent;
25+
editable?: boolean;
26+
placeholderComponent?: PlaceholderFunction;
27+
slashCommandConfig?: SlashCommandConfig;
28+
}
29+
30+
const ChatEditor = forwardRef<{ editor: TiptapEditor | null }, ChatEditorProps>(
31+
(
32+
{
33+
initialContent,
34+
editable = true,
35+
placeholderComponent,
36+
slashCommandConfig,
37+
},
38+
ref,
39+
) => {
40+
const previousContentRef = useRef<JSONContent>(initialContent);
41+
42+
const mentionConfigs = useMemo(() => {
43+
const configs: MentionConfig[] = [];
44+
45+
if (slashCommandConfig) {
46+
configs.push({
47+
trigger: "/",
48+
handleSearch: slashCommandConfig.handleSearch,
49+
});
50+
}
51+
52+
return configs;
53+
}, [slashCommandConfig]);
54+
55+
const extensions = useMemo(
56+
() => [
57+
...shared.getExtensions(placeholderComponent),
58+
...mentionConfigs.map((config) => mention(config)),
59+
],
60+
[mentionConfigs, placeholderComponent],
61+
);
62+
63+
const editor = useEditor(
64+
{
65+
extensions,
66+
editable,
67+
content: shared.isValidTiptapContent(initialContent)
68+
? initialContent
69+
: shared.EMPTY_TIPTAP_DOC,
70+
onCreate: ({ editor }) => {
71+
editor.view.dom.setAttribute("spellcheck", "false");
72+
editor.view.dom.setAttribute("autocomplete", "off");
73+
editor.view.dom.setAttribute("autocapitalize", "off");
74+
},
75+
immediatelyRender: false,
76+
shouldRerenderOnTransaction: false,
77+
parseOptions: { preserveWhitespace: "full" },
78+
},
79+
[extensions],
80+
);
81+
82+
useEffect(() => {
83+
if (ref && typeof ref === "object") {
84+
ref.current = { editor };
85+
}
86+
}, [editor, ref]);
87+
88+
useEffect(() => {
89+
if (editor && previousContentRef.current !== initialContent) {
90+
previousContentRef.current = initialContent;
91+
if (!editor.isFocused) {
92+
if (shared.isValidTiptapContent(initialContent)) {
93+
editor.commands.setContent(initialContent, {
94+
parseOptions: { preserveWhitespace: "full" },
95+
});
96+
}
97+
}
98+
}
99+
}, [editor, initialContent]);
100+
101+
useEffect(() => {
102+
if (editor) {
103+
editor.setEditable(editable);
104+
}
105+
}, [editor, editable]);
106+
107+
return (
108+
<EditorContent editor={editor} className="tiptap-root" role="textbox" />
109+
);
110+
},
111+
);
112+
113+
ChatEditor.displayName = "ChatEditor";
114+
115+
export default ChatEditor;

packages/tiptap/src/editor/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ interface EditorProps {
3030
initialContent?: JSONContent;
3131
editable?: boolean;
3232
setContentFromOutside?: boolean;
33-
mentionConfig: MentionConfig;
33+
mentionConfig?: MentionConfig;
3434
placeholderComponent?: PlaceholderFunction;
3535
fileHandlerConfig?: FileHandlerConfig;
3636
}
@@ -67,7 +67,7 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>(
6767
const extensions = useMemo(
6868
() => [
6969
...shared.getExtensions(placeholderComponent, fileHandlerConfig),
70-
mention(mentionConfig),
70+
...(mentionConfig ? [mention(mentionConfig)] : []),
7171
],
7272
[mentionConfig, placeholderComponent, fileHandlerConfig],
7373
);

0 commit comments

Comments
 (0)