mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-14 09:26:52 +02:00
Add actions support to chat adapter protocol (#67)
* feat: add protocol-specific actions to chat adapters Extends the chat adapter system with support for slash commands and protocol-specific actions without parameters. New features: - ChatAction type system for defining simple commands - Base adapter getActions() and executeAction() methods - NIP-29 /join and /leave slash commands - Slash command parser for detecting /commands - ChatViewer integration with toast notifications Example usage in NIP-29 groups: /join - Request to join the group /leave - Leave the group The action system is extensible and can be enhanced with parameterized actions (e.g., /kick <user>, /ban <user>) in future iterations. Tests: All 804 tests passing Build: Successful Lint: No errors * feat: add autocomplete for slash commands Extends the chat system with autocomplete UI for slash commands, making actions discoverable and easier to execute. New features: - SlashCommandSuggestionList component with keyboard navigation - Slash command search integrated into MentionEditor - Autocomplete popup shows command name and description - Arrow keys for navigation, Enter to select, Escape to close - Filters available commands based on typed text When typing "/" in chat, users now see: /join Request to join the group /leave Leave the group The autocomplete uses the same TipTap suggestion system as @mentions and :emoji:, providing a consistent UX across all autocomplete features. Tests: All 804 tests passing Build: Successful Lint: 1 warning fixed (unused parameter) * feat: execute slash commands immediately on autocomplete selection When selecting a slash command from the autocomplete menu, the action now executes immediately and clears the input, providing a more streamlined UX. Changes: - Added onCommandExecute prop to MentionEditor - Modified slash command suggestion to call onCommandExecute on selection - Clears editor content immediately after selection - Added handleCommandExecute callback in ChatViewer - Executes action and shows toast notifications Before: Type "/" → select "/join" → press Enter → executes After: Type "/" → select "/join" → executes immediately This matches the expected behavior for command selection - when you choose a command from autocomplete, you want to execute it, not just insert it into the text field. Tests: All 804 tests passing Build: Successful Lint: No new errors * feat: make chat actions context-aware and filter by membership status Actions are now intelligently filtered based on the user's membership in the conversation, providing a cleaner and more intuitive UX. Changes: - Added GetActionsOptions type with conversation and activePubkey - Updated base adapter getActions() to accept optional context - Modified NIP-29 adapter to filter actions based on membership: - /join: only shown when user is NOT a member/admin - /leave: only shown when user IS a member - Updated ChatViewer to pass conversation and user context to searchCommands - Moved searchCommands callback after conversation is defined This prevents showing irrelevant commands like "/join" when you're already a member, or "/leave" when you haven't joined yet. The autocomplete menu now only displays actions that are actually executable in the current context. Implementation notes: - NIP-29 uses getAllActions() fallback when context unavailable - Membership determined by checking conversation.participants array - Other protocols return empty array by default (no actions yet) Tests: All 804 tests passing Build: Successful Lint: No new errors --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,8 +23,13 @@ import {
|
||||
EmojiSuggestionList,
|
||||
type EmojiSuggestionListHandle,
|
||||
} from "./EmojiSuggestionList";
|
||||
import {
|
||||
SlashCommandSuggestionList,
|
||||
type SlashCommandSuggestionListHandle,
|
||||
} from "./SlashCommandSuggestionList";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
/**
|
||||
@@ -50,6 +55,8 @@ export interface MentionEditorProps {
|
||||
onSubmit?: (content: string, emojiTags: EmojiTag[]) => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
searchCommands?: (query: string) => Promise<ChatAction[]>;
|
||||
onCommandExecute?: (action: ChatAction) => Promise<void>;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -152,6 +159,8 @@ export const MentionEditor = forwardRef<
|
||||
onSubmit,
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
searchCommands,
|
||||
onCommandExecute,
|
||||
autoFocus = false,
|
||||
className = "",
|
||||
},
|
||||
@@ -335,6 +344,101 @@ export const MentionEditor = forwardRef<
|
||||
[searchEmojis],
|
||||
);
|
||||
|
||||
// Create slash command suggestion configuration for / commands
|
||||
const slashCommandSuggestion: Omit<SuggestionOptions, "editor"> | null =
|
||||
useMemo(
|
||||
() =>
|
||||
searchCommands
|
||||
? {
|
||||
char: "/",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchCommands(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: any;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(
|
||||
SlashCommandSuggestionList,
|
||||
{
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
editor: props.editor,
|
||||
},
|
||||
);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect:
|
||||
props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "top-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect:
|
||||
props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[searchCommands],
|
||||
);
|
||||
|
||||
// Helper function to serialize editor content with mentions and emojis
|
||||
const serializeContent = useCallback(
|
||||
(editorInstance: any): SerializedContent => {
|
||||
@@ -503,8 +607,49 @@ export const MentionEditor = forwardRef<
|
||||
);
|
||||
}
|
||||
|
||||
// Add slash command extension if search is provided
|
||||
if (slashCommandSuggestion) {
|
||||
const SlashCommand = Mention.extend({
|
||||
name: "slashCommand",
|
||||
});
|
||||
|
||||
exts.push(
|
||||
SlashCommand.configure({
|
||||
HTMLAttributes: {
|
||||
class: "slash-command",
|
||||
},
|
||||
suggestion: {
|
||||
...slashCommandSuggestion,
|
||||
command: ({ editor, props }: any) => {
|
||||
// props is the ChatAction
|
||||
// Execute the command immediately and clear the editor
|
||||
editor.commands.clearContent();
|
||||
if (onCommandExecute) {
|
||||
// Execute action asynchronously
|
||||
onCommandExecute(props).catch((error) => {
|
||||
console.error(
|
||||
"[MentionEditor] Command execution failed:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `/${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [mentionSuggestion, emojiSuggestion, placeholder]);
|
||||
}, [
|
||||
mentionSuggestion,
|
||||
emojiSuggestion,
|
||||
slashCommandSuggestion,
|
||||
onCommandExecute,
|
||||
placeholder,
|
||||
]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
|
||||
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal file
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
import { Terminal } from "lucide-react";
|
||||
|
||||
export interface SlashCommandSuggestionListProps {
|
||||
items: ChatAction[];
|
||||
command: (item: ChatAction) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface SlashCommandSuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
export const SlashCommandSuggestionList = forwardRef<
|
||||
SlashCommandSuggestionListHandle,
|
||||
SlashCommandSuggestionListProps
|
||||
>(({ items, command, onClose }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keyboard navigation
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey) {
|
||||
if (items[selectedIndex]) {
|
||||
command(items[selectedIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.children[selectedIndex];
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset selected index when items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover shadow-md"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.name}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => command(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<Terminal className="size-4 flex-shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium font-mono">
|
||||
/{item.name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SlashCommandSuggestionList.displayName = "SlashCommandSuggestionList";
|
||||
Reference in New Issue
Block a user