mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +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({
|
||||
width: 450,
|
||||
height: 550,
|
||||
height: 750,
|
||||
x,
|
||||
y,
|
||||
...(process.platform === 'darwin'
|
||||
|
|
@ -92,7 +92,7 @@ export function registerQuickAsk(): void {
|
|||
|
||||
pendingText = text;
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
@ -101,15 +101,20 @@ export function registerQuickAsk(): void {
|
|||
}
|
||||
|
||||
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) => {
|
||||
pendingMode = mode;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, () => {
|
||||
return pendingMode;
|
||||
ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, (event) => {
|
||||
if (quickAskWindow && !quickAskWindow.isDestroyed() && event.sender.id === quickAskWindow.webContents.id) {
|
||||
return pendingMode;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => {
|
||||
|
|
|
|||
|
|
@ -139,10 +139,13 @@ export function PromptsContent() {
|
|||
id="prompt-template"
|
||||
value={formData.prompt}
|
||||
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}
|
||||
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 className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
createElement,
|
||||
forwardRef,
|
||||
|
|
@ -34,8 +34,6 @@ export interface InlineMentionEditorRef {
|
|||
statusLabel: string | null,
|
||||
statusKind?: "pending" | "processing" | "ready" | "failed"
|
||||
) => void;
|
||||
insertActionChip: (name: string) => void;
|
||||
getSelectedAction: () => string | null;
|
||||
}
|
||||
|
||||
interface InlineMentionEditorProps {
|
||||
|
|
@ -44,7 +42,6 @@ interface InlineMentionEditorProps {
|
|||
onMentionClose?: () => void;
|
||||
onActionTrigger?: (query: string) => void;
|
||||
onActionClose?: () => void;
|
||||
onActionRemove?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||
onDocumentRemove?: (docId: number, docType?: string) => void;
|
||||
|
|
@ -57,7 +54,6 @@ interface InlineMentionEditorProps {
|
|||
|
||||
// Unique data attribute to identify chip elements
|
||||
const CHIP_DATA_ATTR = "data-mention-chip";
|
||||
const ACTION_CHIP_ATTR = "data-action-chip";
|
||||
const CHIP_ID_ATTR = "data-mention-id";
|
||||
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
|
||||
const CHIP_STATUS_ATTR = "data-mention-status";
|
||||
|
|
@ -98,7 +94,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
onMentionClose,
|
||||
onActionTrigger,
|
||||
onActionClose,
|
||||
onActionRemove,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onDocumentRemove,
|
||||
|
|
@ -128,13 +123,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
|
||||
useEffect(() => {
|
||||
if (!initialText || !editorRef.current) return;
|
||||
// Insert the text and add trailing line breaks for typing space
|
||||
editorRef.current.innerText = initialText;
|
||||
editorRef.current.appendChild(document.createElement("br"));
|
||||
editorRef.current.appendChild(document.createElement("br"));
|
||||
setIsEmpty(false);
|
||||
onChange?.(initialText, Array.from(mentionedDocs.values()));
|
||||
// Place cursor at the end of the content
|
||||
editorRef.current.focus();
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
|
@ -142,7 +135,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
// Scroll to cursor via a temporary anchor element
|
||||
const anchor = document.createElement("span");
|
||||
range.insertNode(anchor);
|
||||
anchor.scrollIntoView({ block: "end" });
|
||||
|
|
@ -174,11 +166,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
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.
|
||||
if (element.hasAttribute(CHIP_DATA_ATTR)) {
|
||||
const title = element.querySelector("[data-mention-title='true']")?.textContent?.trim();
|
||||
|
|
@ -292,115 +279,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
[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
|
||||
const insertDocumentChip = useCallback(
|
||||
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
||||
|
|
@ -596,8 +474,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
insertDocumentChip,
|
||||
removeDocumentChip,
|
||||
setDocumentChipStatus,
|
||||
insertActionChip,
|
||||
getSelectedAction,
|
||||
}));
|
||||
|
||||
// Handle input changes
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import {
|
|||
AlertCircle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clipboard,
|
||||
Globe,
|
||||
Plus,
|
||||
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 = () => {
|
||||
// Document mention state (atoms persist across component remounts)
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
|
|
@ -304,6 +344,7 @@ const Composer: FC = () => {
|
|||
const [actionQuery, setActionQuery] = useState("");
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const composerBoxRef = useRef<HTMLDivElement>(null);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
|
|
@ -440,34 +481,46 @@ const Composer: FC = () => {
|
|||
}
|
||||
}, [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(
|
||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||
let userText = editorRef.current?.getText() ?? "";
|
||||
const trigger = `/${actionQuery}`;
|
||||
if (userText.endsWith(trigger)) {
|
||||
userText = userText.slice(0, -trigger.length).trimEnd();
|
||||
}
|
||||
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().send();
|
||||
editorRef.current?.clear();
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
},
|
||||
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
|
||||
);
|
||||
|
||||
if (clipboardInitialText) {
|
||||
const finalPrompt = action.prompt.replace("{selection}", clipboardInitialText);
|
||||
window.electronAPI?.setQuickAskMode(action.mode);
|
||||
aui.composer().setText(finalPrompt);
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
} else {
|
||||
pendingActionRef.current = action;
|
||||
editorRef.current?.insertActionChip(action.name);
|
||||
}
|
||||
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([]);
|
||||
setSidebarDocs([]);
|
||||
},
|
||||
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
|
||||
);
|
||||
|
||||
const handleActionRemove = useCallback(() => {
|
||||
pendingActionRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
|
|
@ -527,11 +580,13 @@ const Composer: FC = () => {
|
|||
return;
|
||||
}
|
||||
if (!showDocumentPopover && !showPromptPicker) {
|
||||
if (pendingActionRef.current) {
|
||||
if (clipboardInitialText) {
|
||||
const userText = editorRef.current?.getText() ?? "";
|
||||
const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText);
|
||||
aui.composer().setText(finalPrompt);
|
||||
pendingActionRef.current = null;
|
||||
const combined = userText
|
||||
? `${userText}\n\n${clipboardInitialText}`
|
||||
: clipboardInitialText;
|
||||
aui.composer().setText(combined);
|
||||
setClipboardInitialText(undefined);
|
||||
}
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
|
|
@ -543,6 +598,7 @@ const Composer: FC = () => {
|
|||
showPromptPicker,
|
||||
isThreadRunning,
|
||||
isBlockedByOtherUser,
|
||||
clipboardInitialText,
|
||||
aui,
|
||||
setMentionedDocuments,
|
||||
setSidebarDocs,
|
||||
|
|
@ -582,14 +638,23 @@ const Composer: FC = () => {
|
|||
);
|
||||
|
||||
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
|
||||
isAiResponding={isAiResponding}
|
||||
respondingToUserId={respondingToUserId}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
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 */}
|
||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
||||
<InlineMentionEditor
|
||||
|
|
@ -599,12 +664,10 @@ const Composer: FC = () => {
|
|||
onMentionClose={handleMentionClose}
|
||||
onActionTrigger={handleActionTrigger}
|
||||
onActionClose={handleActionClose}
|
||||
onActionRemove={handleActionRemove}
|
||||
onChange={handleEditorChange}
|
||||
onDocumentRemove={handleDocumentRemove}
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
initialText={clipboardInitialText}
|
||||
className="min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -638,7 +701,7 @@ const Composer: FC = () => {
|
|||
createPortal(
|
||||
<PromptPicker
|
||||
ref={promptPickerRef}
|
||||
onSelect={handleActionSelect}
|
||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||
onDone={() => {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
|
|
@ -646,9 +709,12 @@ const Composer: FC = () => {
|
|||
externalSearch={actionQuery}
|
||||
containerStyle={{
|
||||
position: "fixed",
|
||||
bottom: editorContainerRef.current
|
||||
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 12}px`
|
||||
: "200px",
|
||||
...(clipboardInitialText && composerBoxRef.current
|
||||
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` }
|
||||
: { bottom: editorContainerRef.current
|
||||
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
||||
: "200px" }
|
||||
),
|
||||
left: editorContainerRef.current
|
||||
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
||||
: "50%",
|
||||
|
|
|
|||
|
|
@ -108,9 +108,8 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
|||
const action = filtered[index];
|
||||
if (!action) return;
|
||||
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
|
||||
onDone();
|
||||
},
|
||||
[filtered, onSelect, onDone]
|
||||
[filtered, onSelect]
|
||||
);
|
||||
|
||||
// Auto-scroll highlighted item into view
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue