fix: harden quick-ask panel, prompt handling, and clipboard UX

This commit is contained in:
CREDO23 2026-03-29 02:54:48 +02:00
parent 6df9eea5a6
commit cfddfa54c6
5 changed files with 113 additions and 164 deletions

View file

@ -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) => {

View file

@ -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">

View file

@ -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

View file

@ -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%",

View file

@ -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