mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
fix: harden quick-ask panel, prompt handling, and clipboard UX
This commit is contained in:
parent
6df9eea5a6
commit
cfddfa54c6
5 changed files with 113 additions and 164 deletions
|
|
@ -33,7 +33,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
||||||
|
|
||||||
quickAskWindow = new BrowserWindow({
|
quickAskWindow = new BrowserWindow({
|
||||||
width: 450,
|
width: 450,
|
||||||
height: 550,
|
height: 750,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
...(process.platform === 'darwin'
|
...(process.platform === 'darwin'
|
||||||
|
|
@ -92,7 +92,7 @@ export function registerQuickAsk(): void {
|
||||||
|
|
||||||
pendingText = text;
|
pendingText = text;
|
||||||
const cursor = screen.getCursorScreenPoint();
|
const cursor = screen.getCursorScreenPoint();
|
||||||
const pos = clampToScreen(cursor.x, cursor.y, 450, 550);
|
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
||||||
createQuickAskWindow(pos.x, pos.y);
|
createQuickAskWindow(pos.x, pos.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,15 +101,20 @@ export function registerQuickAsk(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
|
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
|
||||||
return pendingText;
|
const text = pendingText;
|
||||||
|
pendingText = '';
|
||||||
|
return text;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.SET_QUICK_ASK_MODE, (_event, mode: string) => {
|
ipcMain.handle(IPC_CHANNELS.SET_QUICK_ASK_MODE, (_event, mode: string) => {
|
||||||
pendingMode = mode;
|
pendingMode = mode;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, () => {
|
ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, (event) => {
|
||||||
|
if (quickAskWindow && !quickAskWindow.isDestroyed() && event.sender.id === quickAskWindow.webContents.id) {
|
||||||
return pendingMode;
|
return pendingMode;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => {
|
ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => {
|
||||||
|
|
|
||||||
|
|
@ -139,10 +139,13 @@ export function PromptsContent() {
|
||||||
id="prompt-template"
|
id="prompt-template"
|
||||||
value={formData.prompt}
|
value={formData.prompt}
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
|
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
|
||||||
placeholder="e.g. Fix the grammar in the following text. Return only the corrected text."
|
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{"{selection}"}</code> to insert the input text. If omitted, the text is appended automatically.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Sparkles, X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
createElement,
|
createElement,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
|
@ -34,8 +34,6 @@ export interface InlineMentionEditorRef {
|
||||||
statusLabel: string | null,
|
statusLabel: string | null,
|
||||||
statusKind?: "pending" | "processing" | "ready" | "failed"
|
statusKind?: "pending" | "processing" | "ready" | "failed"
|
||||||
) => void;
|
) => void;
|
||||||
insertActionChip: (name: string) => void;
|
|
||||||
getSelectedAction: () => string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InlineMentionEditorProps {
|
interface InlineMentionEditorProps {
|
||||||
|
|
@ -44,7 +42,6 @@ interface InlineMentionEditorProps {
|
||||||
onMentionClose?: () => void;
|
onMentionClose?: () => void;
|
||||||
onActionTrigger?: (query: string) => void;
|
onActionTrigger?: (query: string) => void;
|
||||||
onActionClose?: () => void;
|
onActionClose?: () => void;
|
||||||
onActionRemove?: () => void;
|
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||||
onDocumentRemove?: (docId: number, docType?: string) => void;
|
onDocumentRemove?: (docId: number, docType?: string) => void;
|
||||||
|
|
@ -57,7 +54,6 @@ interface InlineMentionEditorProps {
|
||||||
|
|
||||||
// Unique data attribute to identify chip elements
|
// Unique data attribute to identify chip elements
|
||||||
const CHIP_DATA_ATTR = "data-mention-chip";
|
const CHIP_DATA_ATTR = "data-mention-chip";
|
||||||
const ACTION_CHIP_ATTR = "data-action-chip";
|
|
||||||
const CHIP_ID_ATTR = "data-mention-id";
|
const CHIP_ID_ATTR = "data-mention-id";
|
||||||
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
|
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
|
||||||
const CHIP_STATUS_ATTR = "data-mention-status";
|
const CHIP_STATUS_ATTR = "data-mention-status";
|
||||||
|
|
@ -98,7 +94,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
onMentionClose,
|
onMentionClose,
|
||||||
onActionTrigger,
|
onActionTrigger,
|
||||||
onActionClose,
|
onActionClose,
|
||||||
onActionRemove,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onChange,
|
onChange,
|
||||||
onDocumentRemove,
|
onDocumentRemove,
|
||||||
|
|
@ -128,13 +123,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialText || !editorRef.current) return;
|
if (!initialText || !editorRef.current) return;
|
||||||
// Insert the text and add trailing line breaks for typing space
|
|
||||||
editorRef.current.innerText = initialText;
|
editorRef.current.innerText = initialText;
|
||||||
editorRef.current.appendChild(document.createElement("br"));
|
editorRef.current.appendChild(document.createElement("br"));
|
||||||
editorRef.current.appendChild(document.createElement("br"));
|
editorRef.current.appendChild(document.createElement("br"));
|
||||||
setIsEmpty(false);
|
setIsEmpty(false);
|
||||||
onChange?.(initialText, Array.from(mentionedDocs.values()));
|
onChange?.(initialText, Array.from(mentionedDocs.values()));
|
||||||
// Place cursor at the end of the content
|
|
||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
|
|
@ -142,7 +135,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
range.collapse(false);
|
range.collapse(false);
|
||||||
sel?.removeAllRanges();
|
sel?.removeAllRanges();
|
||||||
sel?.addRange(range);
|
sel?.addRange(range);
|
||||||
// Scroll to cursor via a temporary anchor element
|
|
||||||
const anchor = document.createElement("span");
|
const anchor = document.createElement("span");
|
||||||
range.insertNode(anchor);
|
range.insertNode(anchor);
|
||||||
anchor.scrollIntoView({ block: "end" });
|
anchor.scrollIntoView({ block: "end" });
|
||||||
|
|
@ -174,11 +166,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
const element = node as Element;
|
const element = node as Element;
|
||||||
|
|
||||||
// Action chips are invisible in the text output
|
|
||||||
if (element.hasAttribute(ACTION_CHIP_ATTR)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve mention chips as inline @title tokens.
|
// Preserve mention chips as inline @title tokens.
|
||||||
if (element.hasAttribute(CHIP_DATA_ATTR)) {
|
if (element.hasAttribute(CHIP_DATA_ATTR)) {
|
||||||
const title = element.querySelector("[data-mention-title='true']")?.textContent?.trim();
|
const title = element.querySelector("[data-mention-title='true']")?.textContent?.trim();
|
||||||
|
|
@ -292,115 +279,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
[focusAtEnd, onDocumentRemove]
|
[focusAtEnd, onDocumentRemove]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createActionChipElement = useCallback(
|
|
||||||
(name: string): HTMLSpanElement => {
|
|
||||||
const chip = document.createElement("span");
|
|
||||||
chip.setAttribute(ACTION_CHIP_ATTR, name);
|
|
||||||
chip.contentEditable = "false";
|
|
||||||
chip.className =
|
|
||||||
"inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded-md bg-accent border text-xs font-medium text-foreground select-none cursor-default";
|
|
||||||
chip.style.userSelect = "none";
|
|
||||||
chip.style.verticalAlign = "baseline";
|
|
||||||
|
|
||||||
const iconSpan = document.createElement("span");
|
|
||||||
iconSpan.className = "flex items-center text-muted-foreground";
|
|
||||||
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
|
||||||
createElement(Sparkles, { className: "h-3 w-3" })
|
|
||||||
);
|
|
||||||
|
|
||||||
const titleSpan = document.createElement("span");
|
|
||||||
titleSpan.textContent = name;
|
|
||||||
|
|
||||||
const removeBtn = document.createElement("button");
|
|
||||||
removeBtn.type = "button";
|
|
||||||
removeBtn.className =
|
|
||||||
"ml-0.5 flex items-center text-muted-foreground hover:text-foreground transition-colors";
|
|
||||||
removeBtn.innerHTML = ReactDOMServer.renderToString(
|
|
||||||
createElement(X, { className: "h-3 w-3", strokeWidth: 2.5 })
|
|
||||||
);
|
|
||||||
removeBtn.onclick = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
chip.remove();
|
|
||||||
onActionRemove?.();
|
|
||||||
focusAtEnd();
|
|
||||||
};
|
|
||||||
|
|
||||||
chip.appendChild(iconSpan);
|
|
||||||
chip.appendChild(titleSpan);
|
|
||||||
chip.appendChild(removeBtn);
|
|
||||||
|
|
||||||
return chip;
|
|
||||||
},
|
|
||||||
[focusAtEnd, onActionRemove]
|
|
||||||
);
|
|
||||||
|
|
||||||
const insertActionChip = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
if (!editorRef.current) return;
|
|
||||||
|
|
||||||
// Remove any existing action chip
|
|
||||||
const existing = editorRef.current.querySelector(`[${ACTION_CHIP_ATTR}]`);
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
// Find and remove the /query text before cursor
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (selection && selection.rangeCount > 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const textNode = range.startContainer;
|
|
||||||
|
|
||||||
if (textNode.nodeType === Node.TEXT_NODE) {
|
|
||||||
const text = textNode.textContent || "";
|
|
||||||
const cursorPos = range.startOffset;
|
|
||||||
|
|
||||||
let slashIndex = -1;
|
|
||||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
|
||||||
if (text[i] === "/") {
|
|
||||||
slashIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slashIndex !== -1) {
|
|
||||||
const beforeSlash = text.slice(0, slashIndex);
|
|
||||||
const afterCursor = text.slice(cursorPos);
|
|
||||||
const chip = createActionChipElement(name);
|
|
||||||
const parent = textNode.parentNode;
|
|
||||||
|
|
||||||
if (parent) {
|
|
||||||
const beforeNode = document.createTextNode(beforeSlash);
|
|
||||||
const afterNode = document.createTextNode(` ${afterCursor}`);
|
|
||||||
parent.insertBefore(beforeNode, textNode);
|
|
||||||
parent.insertBefore(chip, textNode);
|
|
||||||
parent.insertBefore(afterNode, textNode);
|
|
||||||
parent.removeChild(textNode);
|
|
||||||
|
|
||||||
const newRange = document.createRange();
|
|
||||||
newRange.setStart(afterNode, 1);
|
|
||||||
newRange.collapse(true);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(newRange);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: insert at beginning
|
|
||||||
const chip = createActionChipElement(name);
|
|
||||||
editorRef.current.insertBefore(chip, editorRef.current.firstChild);
|
|
||||||
editorRef.current.insertBefore(document.createTextNode(" "), chip.nextSibling);
|
|
||||||
focusAtEnd();
|
|
||||||
},
|
|
||||||
[createActionChipElement, focusAtEnd]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSelectedAction = useCallback((): string | null => {
|
|
||||||
if (!editorRef.current) return null;
|
|
||||||
const chip = editorRef.current.querySelector(`[${ACTION_CHIP_ATTR}]`);
|
|
||||||
return chip?.getAttribute(ACTION_CHIP_ATTR) ?? null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Insert a document chip at the current cursor position
|
// Insert a document chip at the current cursor position
|
||||||
const insertDocumentChip = useCallback(
|
const insertDocumentChip = useCallback(
|
||||||
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||||
|
|
@ -596,8 +474,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
insertDocumentChip,
|
insertDocumentChip,
|
||||||
removeDocumentChip,
|
removeDocumentChip,
|
||||||
setDocumentChipStatus,
|
setDocumentChipStatus,
|
||||||
insertActionChip,
|
|
||||||
getSelectedAction,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Handle input changes
|
// Handle input changes
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clipboard,
|
||||||
Globe,
|
Globe,
|
||||||
Plus,
|
Plus,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
|
@ -294,6 +297,43 @@ const ConnectToolsBanner: FC<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) =
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDismiss }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isLong = text.length > 120;
|
||||||
|
const preview = isLong ? `${text.slice(0, 120)}…` : text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-3 mt-2 rounded-lg border border-border/40 bg-background/60">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Clipboard className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">From clipboard</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{isLong && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<p className="text-xs text-foreground/80 whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||||
|
{expanded ? text : preview}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Composer: FC = () => {
|
const Composer: FC = () => {
|
||||||
// Document mention state (atoms persist across component remounts)
|
// Document mention state (atoms persist across component remounts)
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||||
|
|
@ -304,6 +344,7 @@ const Composer: FC = () => {
|
||||||
const [actionQuery, setActionQuery] = useState("");
|
const [actionQuery, setActionQuery] = useState("");
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerBoxRef = useRef<HTMLDivElement>(null);
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||||
const { search_space_id, chat_id } = useParams();
|
const { search_space_id, chat_id } = useParams();
|
||||||
|
|
@ -440,34 +481,46 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
}, [showPromptPicker]);
|
}, [showPromptPicker]);
|
||||||
|
|
||||||
// Pending action prompt stored when user picks an action
|
|
||||||
const pendingActionRef = useRef<{ name: string; prompt: string; mode: "transform" | "explore" } | null>(null);
|
|
||||||
|
|
||||||
const handleActionSelect = useCallback(
|
const handleActionSelect = useCallback(
|
||||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||||
setShowPromptPicker(false);
|
let userText = editorRef.current?.getText() ?? "";
|
||||||
setActionQuery("");
|
const trigger = `/${actionQuery}`;
|
||||||
|
if (userText.endsWith(trigger)) {
|
||||||
if (clipboardInitialText) {
|
userText = userText.slice(0, -trigger.length).trimEnd();
|
||||||
const finalPrompt = action.prompt.replace("{selection}", clipboardInitialText);
|
}
|
||||||
window.electronAPI?.setQuickAskMode(action.mode);
|
const finalPrompt = action.prompt.includes("{selection}")
|
||||||
|
? action.prompt.replace("{selection}", () => userText)
|
||||||
|
: userText ? `${action.prompt}\n\n${userText}` : action.prompt;
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
aui.composer().send();
|
aui.composer().send();
|
||||||
editorRef.current?.clear();
|
editorRef.current?.clear();
|
||||||
|
setShowPromptPicker(false);
|
||||||
|
setActionQuery("");
|
||||||
|
setMentionedDocuments([]);
|
||||||
|
setSidebarDocs([]);
|
||||||
|
},
|
||||||
|
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleQuickAskSelect = useCallback(
|
||||||
|
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||||
|
if (!clipboardInitialText) return;
|
||||||
|
window.electronAPI?.setQuickAskMode(action.mode);
|
||||||
|
const finalPrompt = action.prompt.includes("{selection}")
|
||||||
|
? action.prompt.replace("{selection}", () => clipboardInitialText)
|
||||||
|
: `${action.prompt}\n\n${clipboardInitialText}`;
|
||||||
|
aui.composer().setText(finalPrompt);
|
||||||
|
aui.composer().send();
|
||||||
|
editorRef.current?.clear();
|
||||||
|
setShowPromptPicker(false);
|
||||||
|
setActionQuery("");
|
||||||
|
setClipboardInitialText(undefined);
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
setSidebarDocs([]);
|
setSidebarDocs([]);
|
||||||
} else {
|
|
||||||
pendingActionRef.current = action;
|
|
||||||
editorRef.current?.insertActionChip(action.name);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
|
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleActionRemove = useCallback(() => {
|
|
||||||
pendingActionRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
|
@ -527,11 +580,13 @@ const Composer: FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!showDocumentPopover && !showPromptPicker) {
|
if (!showDocumentPopover && !showPromptPicker) {
|
||||||
if (pendingActionRef.current) {
|
if (clipboardInitialText) {
|
||||||
const userText = editorRef.current?.getText() ?? "";
|
const userText = editorRef.current?.getText() ?? "";
|
||||||
const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText);
|
const combined = userText
|
||||||
aui.composer().setText(finalPrompt);
|
? `${userText}\n\n${clipboardInitialText}`
|
||||||
pendingActionRef.current = null;
|
: clipboardInitialText;
|
||||||
|
aui.composer().setText(combined);
|
||||||
|
setClipboardInitialText(undefined);
|
||||||
}
|
}
|
||||||
aui.composer().send();
|
aui.composer().send();
|
||||||
editorRef.current?.clear();
|
editorRef.current?.clear();
|
||||||
|
|
@ -543,6 +598,7 @@ const Composer: FC = () => {
|
||||||
showPromptPicker,
|
showPromptPicker,
|
||||||
isThreadRunning,
|
isThreadRunning,
|
||||||
isBlockedByOtherUser,
|
isBlockedByOtherUser,
|
||||||
|
clipboardInitialText,
|
||||||
aui,
|
aui,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setSidebarDocs,
|
setSidebarDocs,
|
||||||
|
|
@ -582,14 +638,23 @@ const Composer: FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
<ComposerPrimitive.Root
|
||||||
|
className="aui-composer-root relative flex w-full flex-col gap-2"
|
||||||
|
style={(showPromptPicker && clipboardInitialText) ? { marginBottom: 220 } : undefined}
|
||||||
|
>
|
||||||
<ChatSessionStatus
|
<ChatSessionStatus
|
||||||
isAiResponding={isAiResponding}
|
isAiResponding={isAiResponding}
|
||||||
respondingToUserId={respondingToUserId}
|
respondingToUserId={respondingToUserId}
|
||||||
currentUserId={currentUser?.id ?? null}
|
currentUserId={currentUser?.id ?? null}
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
/>
|
/>
|
||||||
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
<div ref={composerBoxRef} className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
||||||
|
{clipboardInitialText && (
|
||||||
|
<ClipboardChip
|
||||||
|
text={clipboardInitialText}
|
||||||
|
onDismiss={() => setClipboardInitialText(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Inline editor with @mention support */}
|
{/* Inline editor with @mention support */}
|
||||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
||||||
<InlineMentionEditor
|
<InlineMentionEditor
|
||||||
|
|
@ -599,12 +664,10 @@ const Composer: FC = () => {
|
||||||
onMentionClose={handleMentionClose}
|
onMentionClose={handleMentionClose}
|
||||||
onActionTrigger={handleActionTrigger}
|
onActionTrigger={handleActionTrigger}
|
||||||
onActionClose={handleActionClose}
|
onActionClose={handleActionClose}
|
||||||
onActionRemove={handleActionRemove}
|
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
onDocumentRemove={handleDocumentRemove}
|
onDocumentRemove={handleDocumentRemove}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
initialText={clipboardInitialText}
|
|
||||||
className="min-h-[24px]"
|
className="min-h-[24px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -638,7 +701,7 @@ const Composer: FC = () => {
|
||||||
createPortal(
|
createPortal(
|
||||||
<PromptPicker
|
<PromptPicker
|
||||||
ref={promptPickerRef}
|
ref={promptPickerRef}
|
||||||
onSelect={handleActionSelect}
|
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
|
@ -646,9 +709,12 @@ const Composer: FC = () => {
|
||||||
externalSearch={actionQuery}
|
externalSearch={actionQuery}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
bottom: editorContainerRef.current
|
...(clipboardInitialText && composerBoxRef.current
|
||||||
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 12}px`
|
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` }
|
||||||
: "200px",
|
: { bottom: editorContainerRef.current
|
||||||
|
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
||||||
|
: "200px" }
|
||||||
|
),
|
||||||
left: editorContainerRef.current
|
left: editorContainerRef.current
|
||||||
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
||||||
: "50%",
|
: "50%",
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,8 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
||||||
const action = filtered[index];
|
const action = filtered[index];
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
|
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
|
||||||
onDone();
|
|
||||||
},
|
},
|
||||||
[filtered, onSelect, onDone]
|
[filtered, onSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-scroll highlighted item into view
|
// Auto-scroll highlighted item into view
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue