@sybilion/uilib 1.3.33 → 1.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +2 -2
- package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.js +18 -4
- package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.js +39 -52
- package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.styl.js +2 -2
- package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptComposer.js +25 -0
- package/dist/esm/components/ui/Chat/ChatPrompt/chatPromptDoc.js +17 -0
- package/dist/esm/components/ui/Chat/ChatPrompt/useChatPromptEditor.js +163 -0
- package/dist/esm/components/ui/Tooltip/Tooltip.styl.js +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/tiptap/slash-mention/SlashSuggestionList.js +53 -0
- package/dist/esm/tiptap/slash-mention/SlashSuggestionList.styl.js +7 -0
- package/dist/esm/tiptap/slash-mention/createSlashMentionExtension.js +151 -0
- package/dist/esm/tiptap/slash-mention/defaultChatSlashItems.js +12 -0
- package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +3 -0
- package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.d.ts +1 -1
- package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.types.d.ts +5 -0
- package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.d.ts +1 -1
- package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.d.ts +6 -0
- package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPromptComposer.d.ts +13 -0
- package/dist/esm/types/src/components/ui/Chat/ChatPrompt/chatPromptDoc.d.ts +3 -0
- package/dist/esm/types/src/components/ui/Chat/ChatPrompt/useChatPromptEditor.d.ts +20 -0
- package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Input/Input.d.ts +1 -1
- package/dist/esm/types/src/docs/pages/ChatSlashCommandsPage.d.ts +1 -0
- package/dist/esm/types/src/index.d.ts +1 -0
- package/dist/esm/types/src/tiptap/slash-mention/SlashSuggestionList.d.ts +12 -0
- package/dist/esm/types/src/tiptap/slash-mention/createSlashMentionExtension.d.ts +21 -0
- package/dist/esm/types/src/tiptap/slash-mention/defaultChatSlashItems.d.ts +4 -0
- package/dist/esm/types/src/tiptap/slash-mention/index.d.ts +5 -0
- package/dist/esm/types/src/tiptap/slash-mention/types.d.ts +25 -0
- package/package.json +15 -1
- package/src/components/ui/Chat/Chat.types.ts +4 -0
- package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +4 -0
- package/src/components/ui/Chat/ChatChrome/ChatChrome.types.ts +5 -0
- package/src/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.ts +43 -0
- package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl +33 -5
- package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl.d.ts +2 -1
- package/src/components/ui/Chat/ChatPrompt/ChatPrompt.tsx +50 -106
- package/src/components/ui/Chat/ChatPrompt/ChatPromptComposer.tsx +93 -0
- package/src/components/ui/Chat/ChatPrompt/chatPromptDoc.ts +18 -0
- package/src/components/ui/Chat/ChatPrompt/useChatPromptEditor.ts +214 -0
- package/src/components/ui/Chat/index.ts +1 -0
- package/src/components/ui/Tooltip/Tooltip.styl +1 -0
- package/src/docs/pages/ChatSlashCommandsPage.tsx +139 -0
- package/src/docs/pages/TooltipPage.tsx +1 -1
- package/src/docs/registry.ts +6 -0
- package/src/index.ts +1 -0
- package/src/tiptap/slash-mention/SlashSuggestionList.styl +48 -0
- package/src/tiptap/slash-mention/SlashSuggestionList.styl.d.ts +11 -0
- package/src/tiptap/slash-mention/SlashSuggestionList.tsx +109 -0
- package/src/tiptap/slash-mention/createSlashMentionExtension.ts +217 -0
- package/src/tiptap/slash-mention/defaultChatSlashItems.ts +18 -0
- package/src/tiptap/slash-mention/index.ts +16 -0
- package/src/tiptap/slash-mention/types.ts +29 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChangeEvent,
|
|
3
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
4
|
+
useCallback,
|
|
5
|
+
useRef,
|
|
6
|
+
} from 'react';
|
|
7
|
+
|
|
8
|
+
import type { Editor } from '@tiptap/core';
|
|
9
|
+
import { EditorContent } from '@tiptap/react';
|
|
10
|
+
import { PaperclipIcon, SendHorizontalIcon } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
import { Button } from '../../Button';
|
|
13
|
+
import type { ChatAttachmentDropItem } from '../Chat.types';
|
|
14
|
+
import S from './ChatPrompt.styl';
|
|
15
|
+
|
|
16
|
+
export type ChatPromptComposerProps = {
|
|
17
|
+
editor: Editor;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
trimmedMessage: string;
|
|
20
|
+
attachments: ChatAttachmentDropItem[];
|
|
21
|
+
attachmentAccept?: string;
|
|
22
|
+
onAttachmentFiles?: (files: File[]) => void;
|
|
23
|
+
onComposerKeyDown: (event: ReactKeyboardEvent) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ChatPromptComposer({
|
|
27
|
+
editor,
|
|
28
|
+
disabled,
|
|
29
|
+
trimmedMessage,
|
|
30
|
+
attachments,
|
|
31
|
+
attachmentAccept,
|
|
32
|
+
onAttachmentFiles,
|
|
33
|
+
onComposerKeyDown,
|
|
34
|
+
}: ChatPromptComposerProps) {
|
|
35
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
const showAttachButton = Boolean(attachmentAccept && onAttachmentFiles);
|
|
37
|
+
|
|
38
|
+
const handleFileInputChange = useCallback(
|
|
39
|
+
(e: ChangeEvent<HTMLInputElement>) => {
|
|
40
|
+
const files = Array.from(e.target.files ?? []);
|
|
41
|
+
e.target.value = '';
|
|
42
|
+
if (files.length > 0) {
|
|
43
|
+
onAttachmentFiles?.(files);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[onAttachmentFiles],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const canSubmit = Boolean(trimmedMessage || attachments.length > 0);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={S.composer}>
|
|
53
|
+
{showAttachButton ? (
|
|
54
|
+
<>
|
|
55
|
+
<input
|
|
56
|
+
ref={fileInputRef}
|
|
57
|
+
type="file"
|
|
58
|
+
accept={attachmentAccept}
|
|
59
|
+
multiple
|
|
60
|
+
className={S.fileInput}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
onChange={handleFileInputChange}
|
|
63
|
+
/>
|
|
64
|
+
<Button
|
|
65
|
+
type="button"
|
|
66
|
+
variant="ghost"
|
|
67
|
+
icon
|
|
68
|
+
size="sm"
|
|
69
|
+
className={S.attachButton}
|
|
70
|
+
aria-label="Attach file"
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
onClick={e => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
fileInputRef.current?.click();
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<PaperclipIcon size={16} />
|
|
78
|
+
</Button>
|
|
79
|
+
</>
|
|
80
|
+
) : null}
|
|
81
|
+
|
|
82
|
+
<div className={S.editorWrap} onKeyDown={onComposerKeyDown}>
|
|
83
|
+
<EditorContent editor={editor} className={S.editorMount} />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className={S.submitColumn}>
|
|
87
|
+
<Button type="submit" size="sm" disabled={disabled || !canSubmit}>
|
|
88
|
+
<SendHorizontalIcon size={16} />
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JSONContent } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
export const CHAT_PROMPT_EMPTY_DOC: JSONContent = {
|
|
4
|
+
type: 'doc',
|
|
5
|
+
content: [{ type: 'paragraph' }],
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function chatPromptParagraphDoc(text: string): JSONContent {
|
|
9
|
+
return {
|
|
10
|
+
type: 'doc',
|
|
11
|
+
content: [
|
|
12
|
+
{
|
|
13
|
+
type: 'paragraph',
|
|
14
|
+
content: text ? [{ type: 'text', text }] : [],
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_CHAT_SLASH_ITEMS,
|
|
13
|
+
createSlashMentionExtension,
|
|
14
|
+
} from '#uilib/tiptap/slash-mention';
|
|
15
|
+
import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
|
|
16
|
+
import type { AnyExtension, Editor } from '@tiptap/core';
|
|
17
|
+
import { Placeholder } from '@tiptap/extensions';
|
|
18
|
+
import { useEditor } from '@tiptap/react';
|
|
19
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
chatPromptSafeEditorDom,
|
|
23
|
+
syncChatPromptComposerHeight,
|
|
24
|
+
} from './ChatPrompt.helpers';
|
|
25
|
+
import { CHAT_PROMPT_EMPTY_DOC, chatPromptParagraphDoc } from './chatPromptDoc';
|
|
26
|
+
|
|
27
|
+
export type UseChatPromptEditorOptions = {
|
|
28
|
+
disabled: boolean;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
slashCommandItems?: SlashCommandItem[];
|
|
31
|
+
prefillMessage?: string | null;
|
|
32
|
+
/** Staged attachment count — Enter-to-send when text empty but files present. */
|
|
33
|
+
attachmentsCount?: number;
|
|
34
|
+
/** Called when user presses Enter to send (after guards). */
|
|
35
|
+
onEnterSubmit: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type UseChatPromptEditorResult = {
|
|
39
|
+
editor: Editor | null;
|
|
40
|
+
trimmedMessage: string;
|
|
41
|
+
resetAfterSend: () => void;
|
|
42
|
+
handleComposerKeyDown: (event: ReactKeyboardEvent) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function useChatPromptEditor({
|
|
46
|
+
disabled,
|
|
47
|
+
placeholder,
|
|
48
|
+
slashCommandItems,
|
|
49
|
+
prefillMessage,
|
|
50
|
+
attachmentsCount = 0,
|
|
51
|
+
onEnterSubmit,
|
|
52
|
+
}: UseChatPromptEditorOptions): UseChatPromptEditorResult {
|
|
53
|
+
const slashOpenRef = useRef(false);
|
|
54
|
+
const suggestionActiveUpdater = useCallback((active: boolean) => {
|
|
55
|
+
slashOpenRef.current = active;
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const slashFinger = JSON.stringify(slashCommandItems ?? null);
|
|
59
|
+
const slashItemsStable = useMemo(() => {
|
|
60
|
+
return slashCommandItems ?? DEFAULT_CHAT_SLASH_ITEMS;
|
|
61
|
+
}, [slashCommandItems, slashFinger]);
|
|
62
|
+
|
|
63
|
+
const placeholderText = useMemo(() => {
|
|
64
|
+
if (placeholder === undefined || placeholder === null) {
|
|
65
|
+
return 'Type a message…';
|
|
66
|
+
}
|
|
67
|
+
const t = String(placeholder).trim();
|
|
68
|
+
return t === '' ? 'Type a message…' : t;
|
|
69
|
+
}, [placeholder]);
|
|
70
|
+
|
|
71
|
+
const ariaLabelComposer = useMemo(() => {
|
|
72
|
+
if (placeholder === undefined || placeholder === null) return 'Message';
|
|
73
|
+
const t = String(placeholder).trim();
|
|
74
|
+
return t === '' ? 'Message' : t;
|
|
75
|
+
}, [placeholder]);
|
|
76
|
+
|
|
77
|
+
const extensions = useMemo(() => {
|
|
78
|
+
const exts: AnyExtension[] = [
|
|
79
|
+
StarterKit.configure({
|
|
80
|
+
heading: false,
|
|
81
|
+
bulletList: false,
|
|
82
|
+
orderedList: false,
|
|
83
|
+
blockquote: false,
|
|
84
|
+
codeBlock: false,
|
|
85
|
+
horizontalRule: false,
|
|
86
|
+
}),
|
|
87
|
+
Placeholder.configure({
|
|
88
|
+
placeholder: placeholderText,
|
|
89
|
+
showOnlyWhenEditable: true,
|
|
90
|
+
showOnlyCurrent: false,
|
|
91
|
+
}),
|
|
92
|
+
];
|
|
93
|
+
if (slashItemsStable.length > 0) {
|
|
94
|
+
exts.push(
|
|
95
|
+
createSlashMentionExtension({
|
|
96
|
+
items: slashItemsStable,
|
|
97
|
+
onSuggestionUiActiveChange: suggestionActiveUpdater,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return exts;
|
|
102
|
+
}, [slashItemsStable, placeholderText, suggestionActiveUpdater]);
|
|
103
|
+
|
|
104
|
+
const [plainDraft, setPlainDraft] = useState('');
|
|
105
|
+
|
|
106
|
+
const editorDomRef = useRef<HTMLElement | null>(null);
|
|
107
|
+
|
|
108
|
+
const bindEditorDom = useCallback(({ editor }: { editor: Editor }) => {
|
|
109
|
+
setPlainDraft(editor.getText());
|
|
110
|
+
queueMicrotask(() => {
|
|
111
|
+
const dom = chatPromptSafeEditorDom(editor);
|
|
112
|
+
if (!dom) return;
|
|
113
|
+
editorDomRef.current = dom;
|
|
114
|
+
syncChatPromptComposerHeight(dom, editor.getText());
|
|
115
|
+
});
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const trimmedMessage = plainDraft.trim();
|
|
119
|
+
|
|
120
|
+
const editor = useEditor(
|
|
121
|
+
{
|
|
122
|
+
extensions,
|
|
123
|
+
content: CHAT_PROMPT_EMPTY_DOC,
|
|
124
|
+
editable: !disabled,
|
|
125
|
+
immediatelyRender: true,
|
|
126
|
+
editorProps: {
|
|
127
|
+
attributes: {
|
|
128
|
+
spellcheck: 'true',
|
|
129
|
+
'aria-label': ariaLabelComposer,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
onTransaction: ({ editor: ed }) => {
|
|
133
|
+
const dom = chatPromptSafeEditorDom(ed);
|
|
134
|
+
if (dom) {
|
|
135
|
+
syncChatPromptComposerHeight(dom, ed.getText());
|
|
136
|
+
}
|
|
137
|
+
setPlainDraft(ed.getText());
|
|
138
|
+
},
|
|
139
|
+
onCreate: bindEditorDom,
|
|
140
|
+
},
|
|
141
|
+
[extensions, disabled, bindEditorDom, ariaLabelComposer],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!editor) return;
|
|
146
|
+
editor.setEditable(!disabled);
|
|
147
|
+
}, [editor, disabled]);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!editor) return;
|
|
151
|
+
const bind = () => {
|
|
152
|
+
editorDomRef.current = chatPromptSafeEditorDom(editor);
|
|
153
|
+
};
|
|
154
|
+
bind();
|
|
155
|
+
let rafId: number | null = null;
|
|
156
|
+
if (typeof window !== 'undefined') {
|
|
157
|
+
rafId = window.requestAnimationFrame(bind);
|
|
158
|
+
}
|
|
159
|
+
return () => {
|
|
160
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
161
|
+
};
|
|
162
|
+
}, [editor]);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!editor || prefillMessage == null || prefillMessage === '') return;
|
|
166
|
+
editor.commands.setContent(chatPromptParagraphDoc(prefillMessage));
|
|
167
|
+
const t = editor.getText();
|
|
168
|
+
setPlainDraft(t);
|
|
169
|
+
queueMicrotask(() => {
|
|
170
|
+
const dom = chatPromptSafeEditorDom(editor);
|
|
171
|
+
if (dom) syncChatPromptComposerHeight(dom, t);
|
|
172
|
+
});
|
|
173
|
+
}, [editor, prefillMessage]);
|
|
174
|
+
|
|
175
|
+
useLayoutEffect(() => {
|
|
176
|
+
if (!editor) return;
|
|
177
|
+
const dom = chatPromptSafeEditorDom(editor);
|
|
178
|
+
if (!dom) return;
|
|
179
|
+
syncChatPromptComposerHeight(dom, plainDraft);
|
|
180
|
+
}, [editor, plainDraft]);
|
|
181
|
+
|
|
182
|
+
const onEnterSubmitRef = useRef(onEnterSubmit);
|
|
183
|
+
onEnterSubmitRef.current = onEnterSubmit;
|
|
184
|
+
|
|
185
|
+
const resetAfterSend = useCallback(() => {
|
|
186
|
+
if (!editor) return;
|
|
187
|
+
editor.commands.clearContent();
|
|
188
|
+
queueMicrotask(() => {
|
|
189
|
+
const dom = chatPromptSafeEditorDom(editor);
|
|
190
|
+
if (dom) syncChatPromptComposerHeight(dom, '');
|
|
191
|
+
});
|
|
192
|
+
setPlainDraft('');
|
|
193
|
+
}, [editor]);
|
|
194
|
+
|
|
195
|
+
const handleComposerKeyDown = useCallback(
|
|
196
|
+
(e: ReactKeyboardEvent) => {
|
|
197
|
+
if (!(e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey))
|
|
198
|
+
return;
|
|
199
|
+
if (!editorDomRef.current?.contains(e.target as Node)) return;
|
|
200
|
+
if (slashOpenRef.current) return;
|
|
201
|
+
if (!trimmedMessage && attachmentsCount === 0) return;
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
onEnterSubmitRef.current();
|
|
204
|
+
},
|
|
205
|
+
[attachmentsCount, trimmedMessage],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
editor,
|
|
210
|
+
trimmedMessage,
|
|
211
|
+
resetAfterSend,
|
|
212
|
+
handleComposerKeyDown,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ChatChrome,
|
|
6
|
+
Message,
|
|
7
|
+
MessageRole,
|
|
8
|
+
type SlashCommandItem,
|
|
9
|
+
} from '#uilib/components/ui/Chat';
|
|
10
|
+
import { PageContentSection } from '#uilib/components/ui/Page';
|
|
11
|
+
import { ScrollRef } from '@homecode/ui';
|
|
12
|
+
|
|
13
|
+
import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
|
|
14
|
+
import { DocsHeaderActions } from '../docsHeaderActions';
|
|
15
|
+
|
|
16
|
+
const NO_QUICK_REPLY_KEYS: ReadonlySet<string> = new Set();
|
|
17
|
+
|
|
18
|
+
/** Sample items so the docs demo still shows a `/` palette (`DEFAULT_CHAT_SLASH_ITEMS` is empty). */
|
|
19
|
+
const DOCS_SAMPLE_SLASH_ITEMS: SlashCommandItem[] = [
|
|
20
|
+
{
|
|
21
|
+
id: 'sample-command',
|
|
22
|
+
label: 'sample-command',
|
|
23
|
+
description:
|
|
24
|
+
'Demo only — define `slashCommandItems` in your app to list real commands.',
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const ASSISTANT_REPLY_TEXT =
|
|
29
|
+
'Demo reply. Picked slash text is `/sample-command` via TipTap Mention (plain-text round-trip with renderText).';
|
|
30
|
+
|
|
31
|
+
function makeMessage(role: MessageRole, text: string): Message {
|
|
32
|
+
return {
|
|
33
|
+
id: crypto.randomUUID(),
|
|
34
|
+
role,
|
|
35
|
+
text,
|
|
36
|
+
timestamp: Date.now(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function ChatSlashCommandsPage() {
|
|
41
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
42
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
43
|
+
const replyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const scrollRef = useRef<ScrollRef>(null);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
return () => {
|
|
48
|
+
if (replyTimeoutRef.current != null) {
|
|
49
|
+
clearTimeout(replyTimeoutRef.current);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const isEmpty = messages.length === 0 && !isLoading;
|
|
55
|
+
const isLastMessageFromUser =
|
|
56
|
+
messages.length > 0 &&
|
|
57
|
+
messages[messages.length - 1]?.role === MessageRole.USER;
|
|
58
|
+
|
|
59
|
+
const onSubmit = useCallback(
|
|
60
|
+
(raw: string) => {
|
|
61
|
+
const text = raw.trim();
|
|
62
|
+
if (!text || isLoading) return;
|
|
63
|
+
|
|
64
|
+
setMessages(prev => [...prev, makeMessage(MessageRole.USER, text)]);
|
|
65
|
+
setIsLoading(true);
|
|
66
|
+
|
|
67
|
+
if (replyTimeoutRef.current != null) {
|
|
68
|
+
clearTimeout(replyTimeoutRef.current);
|
|
69
|
+
}
|
|
70
|
+
replyTimeoutRef.current = setTimeout(() => {
|
|
71
|
+
replyTimeoutRef.current = null;
|
|
72
|
+
setMessages(prev => [
|
|
73
|
+
...prev,
|
|
74
|
+
makeMessage(MessageRole.ASSISTANT, ASSISTANT_REPLY_TEXT),
|
|
75
|
+
]);
|
|
76
|
+
setIsLoading(false);
|
|
77
|
+
}, 900);
|
|
78
|
+
},
|
|
79
|
+
[isLoading],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<AppPageHeader
|
|
85
|
+
breadcrumbs={[{ label: 'Chat' }, { label: 'Chat slash commands' }]}
|
|
86
|
+
title="Chat slash commands"
|
|
87
|
+
subheader={`Slash palette uses TipTap Mention with "/" as the trigger. Library default item list is empty — pass slashCommandItems from the app (this page uses a sample item for the demo).`}
|
|
88
|
+
actions={<DocsHeaderActions />}
|
|
89
|
+
/>
|
|
90
|
+
<PageContentSection>
|
|
91
|
+
<p style={{ marginBottom: 16, fontSize: 14, lineHeight: 1.5 }}>
|
|
92
|
+
Same{' '}
|
|
93
|
+
<Link className="underline underline-offset-2" to="/docs/chat">
|
|
94
|
+
Chat
|
|
95
|
+
</Link>{' '}
|
|
96
|
+
shell below: scrolling history, empty state, disclaimer, composer.
|
|
97
|
+
Type <kbd className="font-mono">/</kbd> at line start or after a
|
|
98
|
+
space; pick with arrows + Enter or click, then send.
|
|
99
|
+
</p>
|
|
100
|
+
<ChatChrome
|
|
101
|
+
showResizeHandle={false}
|
|
102
|
+
resizeHandle={undefined}
|
|
103
|
+
onClose={undefined}
|
|
104
|
+
isEmpty={isEmpty}
|
|
105
|
+
renderPresets={() => null}
|
|
106
|
+
messages={messages}
|
|
107
|
+
onQuickReply={() => {}}
|
|
108
|
+
suppressedQuickReplyKeys={NO_QUICK_REPLY_KEYS}
|
|
109
|
+
isLoading={isLoading}
|
|
110
|
+
scriptContinueLabel={undefined}
|
|
111
|
+
onScriptContinue={undefined}
|
|
112
|
+
showBranchActionsRow={false}
|
|
113
|
+
showSyntheticBranchButtons={false}
|
|
114
|
+
unusedBranchKeys={[]}
|
|
115
|
+
isScriptComplete={false}
|
|
116
|
+
onGenerateDashboard={undefined}
|
|
117
|
+
generatingDashboard={false}
|
|
118
|
+
onGenerateDashboardClick={() => {}}
|
|
119
|
+
showInlinePresets={false}
|
|
120
|
+
isLastMessageFromUser={isLastMessageFromUser}
|
|
121
|
+
scrollRef={scrollRef}
|
|
122
|
+
effectiveScopeId="docs-chat-slash-inline"
|
|
123
|
+
onPromptSubmit={onSubmit}
|
|
124
|
+
onChatDeleted={() => {}}
|
|
125
|
+
slashCommandItems={DOCS_SAMPLE_SLASH_ITEMS}
|
|
126
|
+
promptPlaceholder='Ask something or type "/" for demo commands…'
|
|
127
|
+
emptyState={{
|
|
128
|
+
title: 'Try a slash command',
|
|
129
|
+
description:
|
|
130
|
+
'This demo mounts one sample slash item. Production: pass slashCommandItems to ChatChrome so `/` opens your palette.',
|
|
131
|
+
additionalContent: (
|
|
132
|
+
<p>Optional empty-state slot via additionalContent.</p>
|
|
133
|
+
),
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
</PageContentSection>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|
package/src/docs/registry.ts
CHANGED
|
@@ -109,6 +109,12 @@ export const DOC_REGISTRY: DocEntry[] = [
|
|
|
109
109
|
section: 'Chat',
|
|
110
110
|
load: () => import('./pages/ChatPage'),
|
|
111
111
|
},
|
|
112
|
+
{
|
|
113
|
+
slug: 'chat-slash-commands',
|
|
114
|
+
title: 'Chat slash commands',
|
|
115
|
+
section: 'Chat',
|
|
116
|
+
load: () => import('./pages/ChatSlashCommandsPage'),
|
|
117
|
+
},
|
|
112
118
|
{
|
|
113
119
|
slug: 'chat-user-csv-attachment',
|
|
114
120
|
title: 'Chat user CSV attachment',
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { DEFAULT_THEME_ACTIVE_COLOR } from './docs/lib/theme';
|
|
|
11
11
|
export * from './sybilion-auth';
|
|
12
12
|
export * from './types/sybilionDatasetSnapshots';
|
|
13
13
|
export * from './contexts/chat-context';
|
|
14
|
+
export * from './tiptap/slash-mention';
|
|
14
15
|
export * from './types/chat-api.types';
|
|
15
16
|
export * from './components/ui/AnalysesSelector';
|
|
16
17
|
export * from './components/ui/AnalysisLineIcon';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
@import 'lib/theme.styl';
|
|
2
|
+
|
|
3
|
+
.root
|
|
4
|
+
display flex
|
|
5
|
+
flex-direction column
|
|
6
|
+
min-width 220px
|
|
7
|
+
max-width min(340px, 90vw)
|
|
8
|
+
max-height min(260px, 40vh)
|
|
9
|
+
overflow-y auto
|
|
10
|
+
padding var(--p-1)
|
|
11
|
+
margin 0
|
|
12
|
+
list-style none
|
|
13
|
+
background var(--popover, var(--card))
|
|
14
|
+
color var(--foreground)
|
|
15
|
+
border 1px solid var(--border)
|
|
16
|
+
border-radius var(--radius-lg, 8px)
|
|
17
|
+
box-shadow 0 8px 24px rgba(0, 0, 0, 0.12)
|
|
18
|
+
font-size var(--text-sm)
|
|
19
|
+
|
|
20
|
+
:global(.dark) &
|
|
21
|
+
box-shadow 0 8px 24px rgba(0, 0, 0, 0.5)
|
|
22
|
+
|
|
23
|
+
.item
|
|
24
|
+
display flex
|
|
25
|
+
flex-direction column
|
|
26
|
+
align-items flex-start
|
|
27
|
+
width 100%
|
|
28
|
+
padding var(--p-1) var(--p-2)
|
|
29
|
+
margin 0
|
|
30
|
+
border none
|
|
31
|
+
border-radius var(--radius-md, 6px)
|
|
32
|
+
background transparent
|
|
33
|
+
color inherit
|
|
34
|
+
text-align left
|
|
35
|
+
cursor pointer
|
|
36
|
+
|
|
37
|
+
&:hover
|
|
38
|
+
background var(--muted)
|
|
39
|
+
|
|
40
|
+
.itemHighlighted
|
|
41
|
+
background var(--muted)
|
|
42
|
+
|
|
43
|
+
.itemLabel
|
|
44
|
+
font-weight 600
|
|
45
|
+
|
|
46
|
+
.itemDesc
|
|
47
|
+
font-size var(--text-xs)
|
|
48
|
+
color var(--muted-foreground)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is automatically generated.
|
|
2
|
+
// Please do not change this file!
|
|
3
|
+
interface CssExports {
|
|
4
|
+
'item': string;
|
|
5
|
+
'itemDesc': string;
|
|
6
|
+
'itemHighlighted': string;
|
|
7
|
+
'itemLabel': string;
|
|
8
|
+
'root': string;
|
|
9
|
+
}
|
|
10
|
+
export const cssExports: CssExports;
|
|
11
|
+
export default cssExports;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import cn from 'classnames';
|
|
2
|
+
import type { RefObject } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useImperativeHandle, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import S from './SlashSuggestionList.styl';
|
|
6
|
+
import type { SlashCommandItem } from './types';
|
|
7
|
+
|
|
8
|
+
export type SlashSuggestionListHandle = {
|
|
9
|
+
onKeyboardEvent: (event: KeyboardEvent) => boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type SlashSuggestionListInnerProps = {
|
|
13
|
+
items: SlashCommandItem[];
|
|
14
|
+
command: (item: SlashCommandItem) => void;
|
|
15
|
+
listHandleRef: RefObject<SlashSuggestionListHandle | null>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function SlashSuggestionListInner({
|
|
19
|
+
items,
|
|
20
|
+
command,
|
|
21
|
+
listHandleRef,
|
|
22
|
+
}: SlashSuggestionListInnerProps) {
|
|
23
|
+
const [selected, setSelected] = useState(0);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setSelected(s => Math.min(s, Math.max(items.length - 1, 0)));
|
|
27
|
+
}, [items.length]);
|
|
28
|
+
|
|
29
|
+
const safeSel = Math.min(selected, Math.max(items.length - 1, 0));
|
|
30
|
+
|
|
31
|
+
const onPick = useCallback(
|
|
32
|
+
(index: number) => {
|
|
33
|
+
const item = items[index];
|
|
34
|
+
if (!item) return;
|
|
35
|
+
command(item);
|
|
36
|
+
},
|
|
37
|
+
[command, items],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const onKeyboardEvent = useCallback(
|
|
41
|
+
(event: KeyboardEvent): boolean => {
|
|
42
|
+
if (items.length === 0) return false;
|
|
43
|
+
if (event.key === 'ArrowDown') {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
setSelected(s => Math.min(s + 1, items.length - 1));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (event.key === 'ArrowUp') {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
setSelected(s => Math.max(s - 1, 0));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (event.key === 'Enter') {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
const max = Math.max(items.length - 1, 0);
|
|
56
|
+
const idx = Math.min(selected, max);
|
|
57
|
+
const item = items[idx];
|
|
58
|
+
if (item) command(item);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
},
|
|
63
|
+
[command, items, selected],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
useImperativeHandle(listHandleRef, () => ({ onKeyboardEvent }), [
|
|
67
|
+
onKeyboardEvent,
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={S.root} role="listbox" aria-label="Slash commands">
|
|
72
|
+
{items.map((item, idx) => (
|
|
73
|
+
<button
|
|
74
|
+
key={`${item.id}-${idx}`}
|
|
75
|
+
type="button"
|
|
76
|
+
role="option"
|
|
77
|
+
aria-selected={idx === safeSel}
|
|
78
|
+
className={cn(S.item, idx === safeSel && S.itemHighlighted)}
|
|
79
|
+
onMouseDown={e => e.preventDefault()}
|
|
80
|
+
onMouseEnter={() => setSelected(idx)}
|
|
81
|
+
onClick={() => onPick(idx)}
|
|
82
|
+
>
|
|
83
|
+
<span className={S.itemLabel}>/{item.label}</span>
|
|
84
|
+
{item.description ? (
|
|
85
|
+
<span className={S.itemDesc}>{item.description}</span>
|
|
86
|
+
) : null}
|
|
87
|
+
</button>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Props forwarded from Tiptap suggestion renderer + our wiring. */
|
|
94
|
+
export type SlashSuggestionListProps = {
|
|
95
|
+
items: SlashCommandItem[];
|
|
96
|
+
command: (item: SlashCommandItem) => void;
|
|
97
|
+
listHandleRef: RefObject<SlashSuggestionListHandle | null>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function SlashSuggestionList(props: SlashSuggestionListProps) {
|
|
101
|
+
if (props.items.length === 0) return null;
|
|
102
|
+
return (
|
|
103
|
+
<SlashSuggestionListInner
|
|
104
|
+
items={props.items}
|
|
105
|
+
command={props.command}
|
|
106
|
+
listHandleRef={props.listHandleRef}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|