Skip to content

Commit baea6f2

Browse files
feat(chat): replace text input with Tiptap editor for slash commands and file mentions
- Add TiptapPromptEditor with ProseMirror-based rich text input - Slash command chips (/command) as inline atom nodes, insertable anywhere in message - File mention chips (@path) anchored to cursor position via virtual float - SlashCommandMenu width matches prompt input via --radix-popover-trigger-width - Selecting a command inserts a chip node instead of immediately submitting - Popover closes on editor blur, reopens on focus - Tab no longer auto-selects commands (only Enter selects) - serializeEditorToText serializes chip nodes to /name and @path for submission
1 parent 0c13932 commit baea6f2

12 files changed

Lines changed: 1259 additions & 161 deletions

File tree

apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx

Lines changed: 61 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
PromptInputAttachment,
44
PromptInputAttachments,
55
type PromptInputMessage,
6-
PromptInputTextarea,
76
usePromptInputController,
87
} from "@superset/ui/ai-elements/prompt-input";
98
import type { ThinkingLevel } from "@superset/ui/ai-elements/thinking-toggle";
@@ -16,15 +15,13 @@ import { useHotkeyDisplay } from "renderer/hotkeys";
1615
import type { SlashCommand } from "../../hooks/useSlashCommands";
1716
import type { ModelOption, PermissionMode } from "../../types";
1817
import { IssueLinkCommand } from "../IssueLinkCommand";
19-
import { MentionAnchor, MentionProvider } from "../MentionPopover";
20-
import { SlashCommandInput } from "../SlashCommandInput";
18+
import { TiptapPromptEditor } from "../TiptapPromptEditor";
2119
import { ChatComposerControls } from "./components/ChatComposerControls";
2220
import { ChatInputDropZone } from "./components/ChatInputDropZone";
2321
import { ChatShortcuts } from "./components/ChatShortcuts";
2422
import { FileDropOverlay } from "./components/FileDropOverlay";
2523
import { LinkedIssues } from "./components/LinkedIssues";
2624
import { QuestionInputOverlay } from "./components/QuestionInputOverlay";
27-
import { SlashCommandPreview } from "./components/SlashCommandPreview";
2825
import type { LinkedIssue } from "./types";
2926
import { getErrorMessage } from "./utils/getErrorMessage";
3027

@@ -92,7 +89,7 @@ export function ChatInputFooter({
9289
useFocusPromptOnPane(isFocused);
9390

9491
// Focus the prompt when the question overlay dismisses (pendingQuestion → null).
95-
// Uses rAF so the textarea has time to mount, register its ref, and browser
92+
// Uses rAF so the editor has time to mount, register its ref, and browser
9693
// focus-stealing from the unmounting overlay has settled.
9794
const { textInput } = usePromptInputController();
9895
const prevPendingQuestionRef = useRef(pendingQuestion);
@@ -163,82 +160,66 @@ export function ChatInputFooter({
163160
onCancel={onQuestionCancel}
164161
/>
165162
) : (
166-
<SlashCommandInput
167-
onCommandSend={onSlashCommandSend}
168-
commands={slashCommands}
163+
<div
164+
ref={inputRootRef}
165+
className={
166+
dragType === "path"
167+
? "relative opacity-50 transition-opacity"
168+
: "relative"
169+
}
169170
>
170-
<MentionProvider cwd={cwd}>
171-
<MentionAnchor>
172-
<div
173-
ref={inputRootRef}
174-
className={
175-
dragType === "path"
176-
? "relative opacity-50 transition-opacity"
177-
: "relative"
178-
}
179-
>
180-
{showFocusHint && (
181-
<span className="pointer-events-none absolute top-3 right-3 z-10 text-xs text-muted-foreground/50 [:focus-within>&]:hidden">
182-
{focusShortcutText} to focus
183-
</span>
184-
)}
185-
<PromptInput
186-
className="[&>[data-slot=input-group]]:rounded-[13px] [&>[data-slot=input-group]]:border-[0.5px] [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:bg-foreground/[0.02]"
187-
onSubmitStart={onSubmitStart}
188-
onSubmitEnd={onSubmitEnd}
189-
onSubmit={handleSend}
190-
multiple
191-
maxFiles={5}
192-
maxFileSize={10 * 1024 * 1024}
193-
globalDrop
194-
>
195-
<ChatShortcuts
196-
isFocused={isFocused}
197-
setIssueLinkOpen={setIssueLinkOpen}
198-
/>
199-
<IssueLinkCommand
200-
open={issueLinkOpen}
201-
onOpenChange={setIssueLinkOpen}
202-
onSelect={addLinkedIssue}
203-
/>
204-
<FileDropOverlay visible={dragType === "files"} />
205-
<PromptInputAttachments>
206-
{renderAttachment ??
207-
((file) => <PromptInputAttachment data={file} />)}
208-
</PromptInputAttachments>
209-
<LinkedIssues
210-
issues={linkedIssues}
211-
onRemove={removeLinkedIssue}
212-
/>
213-
<SlashCommandPreview
214-
cwd={cwd}
215-
slashCommands={slashCommands}
216-
/>
217-
<PromptInputTextarea
218-
placeholder="Ask to make changes, @mention files, run /commands"
219-
className="min-h-10"
220-
/>
221-
<ChatComposerControls
222-
availableModels={availableModels}
223-
selectedModel={selectedModel}
224-
setSelectedModel={setSelectedModel}
225-
modelSelectorOpen={modelSelectorOpen}
226-
setModelSelectorOpen={setModelSelectorOpen}
227-
permissionMode={permissionMode}
228-
setPermissionMode={setPermissionMode}
229-
thinkingLevel={thinkingLevel}
230-
setThinkingLevel={setThinkingLevel}
231-
canAbort={canAbort}
232-
submitStatus={submitStatus}
233-
submitDisabled={submitDisabled}
234-
onStop={onStop}
235-
onLinkIssue={() => setIssueLinkOpen(true)}
236-
/>
237-
</PromptInput>
238-
</div>
239-
</MentionAnchor>
240-
</MentionProvider>
241-
</SlashCommandInput>
171+
<PromptInput
172+
className="[&>[data-slot=input-group]]:rounded-[13px] [&>[data-slot=input-group]]:border-[0.5px] [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:bg-foreground/[0.02]"
173+
onSubmitStart={onSubmitStart}
174+
onSubmitEnd={onSubmitEnd}
175+
onSubmit={handleSend}
176+
multiple
177+
maxFiles={5}
178+
maxFileSize={10 * 1024 * 1024}
179+
globalDrop
180+
>
181+
<ChatShortcuts
182+
isFocused={isFocused}
183+
setIssueLinkOpen={setIssueLinkOpen}
184+
/>
185+
<IssueLinkCommand
186+
open={issueLinkOpen}
187+
onOpenChange={setIssueLinkOpen}
188+
onSelect={addLinkedIssue}
189+
/>
190+
<FileDropOverlay visible={dragType === "files"} />
191+
<PromptInputAttachments>
192+
{renderAttachment ??
193+
((file) => <PromptInputAttachment data={file} />)}
194+
</PromptInputAttachments>
195+
<LinkedIssues
196+
issues={linkedIssues}
197+
onRemove={removeLinkedIssue}
198+
/>
199+
<TiptapPromptEditor
200+
cwd={cwd}
201+
slashCommands={slashCommands}
202+
placeholder="Ask to make changes, @mention files, run /commands"
203+
focusShortcutText={showFocusHint ? focusShortcutText : undefined}
204+
/>
205+
<ChatComposerControls
206+
availableModels={availableModels}
207+
selectedModel={selectedModel}
208+
setSelectedModel={setSelectedModel}
209+
modelSelectorOpen={modelSelectorOpen}
210+
setModelSelectorOpen={setModelSelectorOpen}
211+
permissionMode={permissionMode}
212+
setPermissionMode={setPermissionMode}
213+
thinkingLevel={thinkingLevel}
214+
setThinkingLevel={setThinkingLevel}
215+
canAbort={canAbort}
216+
submitStatus={submitStatus}
217+
submitDisabled={submitDisabled}
218+
onStop={onStop}
219+
onLinkIssue={() => setIssueLinkOpen(true)}
220+
/>
221+
</PromptInput>
222+
</div>
242223
)}
243224
<div className="py-1.5" />
244225
</div>

apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export function SlashCommandMenu({
2828
<PopoverContent
2929
side="top"
3030
align="start"
31-
sideOffset={0}
32-
className="w-[min(44rem,calc(100vw-2rem))] p-0 text-xs"
31+
sideOffset={4}
32+
className="w-[var(--radix-popover-trigger-width)] p-0 text-xs"
3333
onOpenAutoFocus={(e) => e.preventDefault()}
3434
onCloseAutoFocus={(e) => e.preventDefault()}
3535
>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { mergeAttributes, Node } from "@tiptap/core";
2+
import {
3+
type NodeViewProps,
4+
NodeViewWrapper,
5+
ReactNodeViewRenderer,
6+
} from "@tiptap/react";
7+
import { cn } from "@superset/ui/utils";
8+
9+
function FileMentionChip({ node, selected }: NodeViewProps) {
10+
const path = node.attrs.path as string;
11+
const name = path.split("/").pop() ?? path;
12+
13+
return (
14+
<NodeViewWrapper as="span" className="inline-block align-middle">
15+
<span
16+
contentEditable={false}
17+
className={cn(
18+
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs text-foreground/90 select-none cursor-default transition-colors",
19+
selected ? "bg-muted-foreground/15" : "bg-muted-foreground/10",
20+
)}
21+
>
22+
<span className="text-muted-foreground">@</span>
23+
<span>{name}</span>
24+
</span>
25+
</NodeViewWrapper>
26+
);
27+
}
28+
29+
export const FileMentionNode = Node.create({
30+
name: "file-mention",
31+
group: "inline",
32+
inline: true,
33+
atom: true,
34+
selectable: true,
35+
draggable: false,
36+
37+
addAttributes() {
38+
return {
39+
path: {
40+
default: null,
41+
parseHTML: (el) => el.getAttribute("data-path"),
42+
renderHTML: (attrs) => ({ "data-path": attrs.path }),
43+
},
44+
};
45+
},
46+
47+
parseHTML() {
48+
return [{ tag: 'span[data-type="file-mention"]' }];
49+
},
50+
51+
renderHTML({ HTMLAttributes }) {
52+
return [
53+
"span",
54+
mergeAttributes({ "data-type": "file-mention" }, HTMLAttributes),
55+
];
56+
},
57+
58+
addNodeView() {
59+
return ReactNodeViewRenderer(FileMentionChip);
60+
},
61+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { mergeAttributes, Node } from "@tiptap/core";
2+
import {
3+
type NodeViewProps,
4+
NodeViewWrapper,
5+
ReactNodeViewRenderer,
6+
} from "@tiptap/react";
7+
import { cn } from "@superset/ui/utils";
8+
import { useCallback, useEffect, useRef } from "react";
9+
10+
function SlashCommandChip({
11+
node,
12+
selected,
13+
updateAttributes,
14+
editor,
15+
getPos,
16+
}: NodeViewProps) {
17+
const name = node.attrs.name as string;
18+
const args = (node.attrs.args as string) ?? "";
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
21+
// Auto-focus the inline input when this chip is freshly inserted.
22+
// Only run on mount; by then the editor view still holds focus (the suggestion
23+
// plugin just committed the chip), so we can safely transfer focus into the input.
24+
useEffect(() => {
25+
inputRef.current?.focus();
26+
// Position cursor at end of any pre-filled args
27+
const len = inputRef.current?.value.length ?? 0;
28+
inputRef.current?.setSelectionRange(len, len);
29+
}, []); // mount-only
30+
31+
const handleChange = useCallback(
32+
(e: React.ChangeEvent<HTMLInputElement>) => {
33+
updateAttributes({ args: e.target.value });
34+
},
35+
[updateAttributes],
36+
);
37+
38+
const handleKeyDown = useCallback(
39+
(e: React.KeyboardEvent<HTMLInputElement>) => {
40+
// Tab or ArrowRight at the end of input → exit chip into the editor
41+
if (
42+
e.key === "Tab" ||
43+
(e.key === "ArrowRight" &&
44+
inputRef.current?.selectionStart === args.length)
45+
) {
46+
e.preventDefault();
47+
e.stopPropagation();
48+
const pos = getPos();
49+
if (pos !== undefined) {
50+
editor.chain().focus().setTextSelection(pos + node.nodeSize).run();
51+
} else {
52+
editor.commands.focus("end");
53+
}
54+
}
55+
// Backspace on empty input → delete the chip and return focus to editor
56+
if (e.key === "Backspace" && args === "") {
57+
e.preventDefault();
58+
e.stopPropagation();
59+
const pos = getPos();
60+
if (pos !== undefined) {
61+
editor.chain()
62+
.focus()
63+
.deleteRange({ from: pos, to: pos + node.nodeSize })
64+
.run();
65+
}
66+
}
67+
},
68+
[args, editor, getPos, node.nodeSize],
69+
);
70+
71+
const placeholder = name;
72+
// Size the input to its content, with a sensible minimum
73+
const displayWidth = Math.max(args.length, placeholder.length, 4);
74+
75+
return (
76+
<NodeViewWrapper as="span" className="inline-block align-middle">
77+
<span
78+
contentEditable={false}
79+
className={cn(
80+
"inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-mono text-xs select-none transition-colors",
81+
selected ? "bg-muted-foreground/15" : "bg-muted-foreground/10",
82+
)}
83+
>
84+
<span className="text-muted-foreground">/</span>
85+
<span className="text-foreground/90">{name}</span>
86+
<span className="text-muted-foreground/60">:</span>
87+
<input
88+
ref={inputRef}
89+
className="bg-transparent border-none outline-none text-foreground/90 placeholder:text-muted-foreground/40 leading-none"
90+
style={{ width: `${displayWidth}ch` }}
91+
value={args}
92+
placeholder={placeholder}
93+
onChange={handleChange}
94+
onKeyDown={handleKeyDown}
95+
// Prevent ProseMirror from handling mouse events on the input
96+
onMouseDown={(e) => e.stopPropagation()}
97+
onClick={(e) => e.stopPropagation()}
98+
/>
99+
</span>
100+
</NodeViewWrapper>
101+
);
102+
}
103+
104+
export const SlashCommandNode = Node.create({
105+
name: "slash-command",
106+
group: "inline",
107+
inline: true,
108+
atom: true,
109+
selectable: true,
110+
draggable: false,
111+
112+
addAttributes() {
113+
return {
114+
name: {
115+
default: null,
116+
parseHTML: (el) => el.getAttribute("data-name"),
117+
renderHTML: (attrs) => ({ "data-name": attrs.name }),
118+
},
119+
args: {
120+
default: "",
121+
parseHTML: (el) => el.getAttribute("data-args") ?? "",
122+
renderHTML: (attrs) => (attrs.args ? { "data-args": attrs.args } : {}),
123+
},
124+
};
125+
},
126+
127+
parseHTML() {
128+
return [{ tag: 'span[data-type="slash-command"]' }];
129+
},
130+
131+
renderHTML({ HTMLAttributes }) {
132+
return [
133+
"span",
134+
mergeAttributes({ "data-type": "slash-command" }, HTMLAttributes),
135+
];
136+
},
137+
138+
addNodeView() {
139+
return ReactNodeViewRenderer(SlashCommandChip);
140+
},
141+
});

0 commit comments

Comments
 (0)