+ {/* ── Search ── */}
+
+ 🔍
+ setSearch(e.target.value)}
+ onKeyDown={handleSearchKeyDown}
+ aria-label={i18n('commandPalette.searchAriaLabel', '搜索操作')}
+ />
+ {search && (
+
+ )}
+
+
+ {/* ── Filter tabs (slide in/out with view mode) ── */}
+ {showFilterTabs && (
+
+
+ {hasQuickActions && (
+
+ )}
+ {hasSettingsItems && (
+
+ )}
+
+ )}
+
+ {/* ── Content ── */}
+
+ {externalLoading && localItems.length === 0 ? (
+
+ ) : !hasResults ? (
+
+
{isSearching ? '🔍' : '📋'}
+
{emptyText}
+ {isSearching && (
+
+ )}
+
+ ) : (
+
+ {showGrouped ? renderGrouped(displayItems) : renderFlat(displayItems)}
+
+ )}
+
+
+ {/* ── Bottom: view mode toggle (always visible) ── */}
+ {!slashMode && (
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/react-neko-chat/src/message-schema.test.ts b/frontend/react-neko-chat/src/message-schema.test.ts
index d4a7375b46..ee8c289491 100644
--- a/frontend/react-neko-chat/src/message-schema.test.ts
+++ b/frontend/react-neko-chat/src/message-schema.test.ts
@@ -31,6 +31,67 @@ describe('message-schema', () => {
expect(props).toEqual({});
});
+ it('preserves quick action props through the window props schema', async () => {
+ const action = {
+ action_id: 'plugin.demo.toggle',
+ type: 'instant',
+ label: 'Demo toggle',
+ description: 'Toggle demo mode',
+ category: 'settings',
+ plugin_id: 'demo',
+ control: 'toggle',
+ current_value: false,
+ quick_action: true,
+ } as const;
+ const onQuickActionExecute = vi.fn(async () => ({
+ ...action,
+ current_value: true,
+ }));
+ const onQuickActionsRequest = vi.fn();
+ const onQuickActionsPreferencesChange = vi.fn();
+ const props = parseChatWindowProps({
+ quickActions: [action],
+ quickActionsPreferences: {
+ pinned: [action.action_id],
+ hidden: [],
+ recent: [],
+ },
+ quickActionsLoading: true,
+ onQuickActionExecute,
+ onQuickActionsRequest,
+ onQuickActionsPreferencesChange,
+ });
+
+ expect(props.quickActions?.[0]?.action_id).toBe(action.action_id);
+ expect(props.quickActionsPreferences?.pinned).toEqual([action.action_id]);
+ expect(props.quickActionsLoading).toBe(true);
+ props.onQuickActionsRequest?.();
+ props.onQuickActionsPreferencesChange?.({ pinned: [], hidden: [action.action_id], recent: [] });
+ await expect(props.onQuickActionExecute?.(action.action_id, true)).resolves.toMatchObject({
+ action_id: action.action_id,
+ current_value: true,
+ });
+ expect(onQuickActionsRequest).toHaveBeenCalledTimes(1);
+ expect(onQuickActionsPreferencesChange).toHaveBeenCalledWith({
+ pinned: [],
+ hidden: [action.action_id],
+ recent: [],
+ });
+ });
+
+ it('accepts async quick action request and preference callbacks', async () => {
+ const onQuickActionsRequest = vi.fn(async () => {});
+ const onQuickActionsPreferencesChange = vi.fn(async () => {});
+ const props = parseChatWindowProps({
+ onQuickActionsRequest,
+ onQuickActionsPreferencesChange,
+ });
+
+ await expect(props.onQuickActionsRequest?.()).resolves.toBeUndefined();
+ await expect(props.onQuickActionsPreferencesChange?.({ pinned: [], hidden: [], recent: [] }))
+ .resolves.toBeUndefined();
+ });
+
it('accepts an avatar interaction callback in window props', () => {
const onAvatarInteraction = vi.fn();
const props = parseChatWindowProps({ onAvatarInteraction });
diff --git a/frontend/react-neko-chat/src/message-schema.ts b/frontend/react-neko-chat/src/message-schema.ts
index 549ec5000f..d51cb0a70e 100644
--- a/frontend/react-neko-chat/src/message-schema.ts
+++ b/frontend/react-neko-chat/src/message-schema.ts
@@ -53,6 +53,48 @@ const galgameOptionSchema = z.object({
text: z.string().min(1),
});
+const commandItemSchema = z.object({
+ action_id: z.string().min(1),
+ type: z.enum(['instant', 'chat_inject', 'navigation']),
+ label: z.string().min(1),
+ description: z.string(),
+ category: z.string(),
+ plugin_id: z.string(),
+ control: z.enum([
+ 'toggle',
+ 'button',
+ 'dropdown',
+ 'number',
+ 'slider',
+ 'text',
+ 'plugin_lifecycle',
+ 'entry_toggle',
+ ]).optional(),
+ current_value: z.unknown().optional(),
+ options: z.array(z.string()).optional(),
+ min: z.number().finite().optional(),
+ max: z.number().finite().optional(),
+ step: z.number().finite().optional(),
+ disabled: z.boolean().optional(),
+ inject_text: z.string().optional(),
+ input_schema: z.record(z.unknown()).optional(),
+ target: z.string().optional(),
+ open_in: z.enum(['new_tab', 'same_tab']).optional(),
+ keywords: z.array(z.string()).optional(),
+ icon: z.string().nullable().optional(),
+ priority: z.number().finite().optional(),
+ section: z.enum(['pinned', 'recent', 'commands']).nullable().optional(),
+ quick_action: z.boolean().optional(),
+}).strict();
+
+const commandPreferencesSchema = z.object({
+ pinned: z.array(z.string()),
+ hidden: z.array(z.string()),
+ recent: z.array(z.string()),
+}).strict();
+
+const voidOrVoidPromiseSchema = z.union([z.void(), z.promise(z.void())]);
+
// Generic ChoicePrompt — composer-anchored "AI 给你出几个选项" UI 组件抽象。
//
// 当前 source:
@@ -207,6 +249,9 @@ export const chatWindowPropsSchema = z.object({
galgameToggleButtonLabel: z.string().optional(),
galgameToggleButtonAriaLabel: z.string().optional(),
galgameLoadingLabel: z.string().optional(),
+ quickActions: z.array(commandItemSchema).optional(),
+ quickActionsPreferences: commandPreferencesSchema.optional(),
+ quickActionsLoading: z.boolean().optional(),
onMessageAction: z.function()
.args(chatMessageSchema, messageActionSchema)
.returns(z.void())
@@ -255,6 +300,18 @@ export const chatWindowPropsSchema = z.object({
.args(galgameOptionSchema)
.returns(z.void())
.optional(),
+ onQuickActionExecute: z.function()
+ .args(z.string(), z.unknown())
+ .returns(z.promise(z.union([commandItemSchema, z.null()])))
+ .optional(),
+ onQuickActionsRequest: z.function()
+ .args()
+ .returns(voidOrVoidPromiseSchema)
+ .optional(),
+ onQuickActionsPreferencesChange: z.function()
+ .args(commandPreferencesSchema)
+ .returns(voidOrVoidPromiseSchema)
+ .optional(),
// Generic ChoicePrompt(mini-game invite 等通用三选项框架)
choicePrompt: choicePromptSchema.optional(),
onChoiceSelect: z.function()
@@ -274,6 +331,8 @@ export type StatusBlock = z.infer