add action chip in composer with prompt prepend at send time

This commit is contained in:
CREDO23 2026-03-28 23:45:11 +02:00
parent c2644aa6a2
commit 407059ce84
2 changed files with 142 additions and 19 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { X } from "lucide-react";
import { Sparkles, X } from "lucide-react";
import {
createElement,
forwardRef,
@ -34,6 +34,8 @@ export interface InlineMentionEditorRef {
statusLabel: string | null,
statusKind?: "pending" | "processing" | "ready" | "failed"
) => void;
insertActionChip: (name: string) => void;
getSelectedAction: () => string | null;
}
interface InlineMentionEditorProps {
@ -42,6 +44,7 @@ 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;
@ -54,6 +57,7 @@ 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";
@ -94,6 +98,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
onMentionClose,
onActionTrigger,
onActionClose,
onActionRemove,
onSubmit,
onChange,
onDocumentRemove,
@ -169,6 +174,11 @@ 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();
@ -282,6 +292,115 @@ 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">) => {
@ -477,6 +596,8 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus,
insertActionChip,
getSelectedAction,
}));
// Handle input changes

View file

@ -439,29 +439,23 @@ const Composer: FC = () => {
}
}, [showActionPicker]);
// Handle action selection: prepend prompt template and auto-submit
// 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" }) => {
setShowActionPicker(false);
setActionQuery("");
if (editorRef.current) {
const text = editorRef.current.getText();
// Remove the /query from the text
const slashIndex = text.lastIndexOf("/");
const userText = slashIndex !== -1 ? text.substring(0, slashIndex).trim() : text;
const finalPrompt = action.prompt.replace("{selection}", userText);
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current.clear();
setMentionedDocuments([]);
setSidebarDocs([]);
}
pendingActionRef.current = action;
editorRef.current?.insertActionChip(action.name);
},
[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) => {
@ -520,7 +514,13 @@ const Composer: FC = () => {
if (isThreadRunning || isBlockedByOtherUser) {
return;
}
if (!showDocumentPopover) {
if (!showDocumentPopover && !showActionPicker) {
if (pendingActionRef.current) {
const userText = editorRef.current?.getText() ?? "";
const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText);
aui.composer().setText(finalPrompt);
pendingActionRef.current = null;
}
aui.composer().send();
editorRef.current?.clear();
setMentionedDocuments([]);
@ -528,6 +528,7 @@ const Composer: FC = () => {
}
}, [
showDocumentPopover,
showActionPicker,
isThreadRunning,
isBlockedByOtherUser,
aui,
@ -586,6 +587,7 @@ const Composer: FC = () => {
onMentionClose={handleMentionClose}
onActionTrigger={handleActionTrigger}
onActionClose={handleActionClose}
onActionRemove={handleActionRemove}
onChange={handleEditorChange}
onDocumentRemove={handleDocumentRemove}
onSubmit={handleSubmit}
@ -633,7 +635,7 @@ const Composer: FC = () => {
containerStyle={{
position: "fixed",
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 12}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`