+
+
+
+
+ {t("chat_window.workspace_llm_manager.available_models", {
+ provider: providerName,
+ })}
+
+
+ {t(
+ "chat_window.workspace_llm_manager.available_models_description"
+ )}
+
+
+ {!missingCredentials && (
+
+ )}
+
{
@@ -128,18 +162,12 @@ export default function LLMSelectorModal({ workspaceSlug = null }) {
);
}}
/>
-
- {hasChanges && (
+ {hasChanges && !missingCredentials && (
+ {name}
+
+
+ );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/index.jsx
new file mode 100644
index 00000000..bff5d17d
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/index.jsx
@@ -0,0 +1,175 @@
+import { useState, useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+import paths from "@/utils/paths";
+import Admin from "@/models/admin";
+import AgentPlugins from "@/models/experimental/agentPlugins";
+import AgentFlows from "@/models/agentFlows";
+import {
+ getDefaultSkills,
+ getConfigurableSkills,
+} from "@/pages/Admin/Agents/skills";
+import useToolsMenuItems from "../../useToolsMenuItems";
+import SkillRow from "./SkillRow";
+import { Wrench } from "@phosphor-icons/react";
+import { useIsAgentSessionActive } from "@/utils/chat/agent";
+
+export default function AgentSkillsTab({
+ highlightedIndex = -1,
+ registerItemCount,
+}) {
+ const { t } = useTranslation();
+ const agentSessionActive = useIsAgentSessionActive();
+ const defaultSkills = getDefaultSkills(t);
+ const configurableSkills = getConfigurableSkills(t);
+ const [disabledDefaults, setDisabledDefaults] = useState([]);
+ const [enabledConfigurable, setEnabledConfigurable] = useState([]);
+ const [importedSkills, setImportedSkills] = useState([]);
+ const [flows, setFlows] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetchSkillSettings();
+ }, []);
+
+ async function fetchSkillSettings() {
+ try {
+ const [prefs, flowsRes] = await Promise.all([
+ Admin.systemPreferencesByFields([
+ "disabled_agent_skills",
+ "default_agent_skills",
+ "imported_agent_skills",
+ ]),
+ AgentFlows.listFlows(),
+ ]);
+
+ if (prefs?.settings) {
+ setDisabledDefaults(prefs.settings.disabled_agent_skills ?? []);
+ setEnabledConfigurable(prefs.settings.default_agent_skills ?? []);
+ setImportedSkills(prefs.settings.imported_agent_skills ?? []);
+ }
+ if (flowsRes?.flows) setFlows(flowsRes.flows);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function toggleItem(arr, item) {
+ return arr.includes(item) ? arr.filter((s) => s !== item) : [...arr, item];
+ }
+
+ function isSkillEnabled(key) {
+ return key in defaultSkills
+ ? !disabledDefaults.includes(key)
+ : enabledConfigurable.includes(key);
+ }
+
+ async function toggleSkill(key) {
+ if (key in defaultSkills) {
+ const updated = toggleItem(disabledDefaults, key);
+ setDisabledDefaults(updated);
+ await Admin.updateSystemPreferences({
+ disabled_agent_skills: updated.join(","),
+ default_agent_skills: enabledConfigurable.join(","),
+ });
+ return;
+ }
+
+ const updated = toggleItem(enabledConfigurable, key);
+ setEnabledConfigurable(updated);
+ await Admin.updateSystemPreferences({
+ disabled_agent_skills: disabledDefaults.join(","),
+ default_agent_skills: updated.join(","),
+ });
+ }
+
+ async function toggleImportedSkill(skill) {
+ const newActive = !skill.active;
+ setImportedSkills((prev) =>
+ prev.map((s) =>
+ s.hubId === skill.hubId ? { ...s, active: newActive } : s
+ )
+ );
+ await AgentPlugins.toggleFeature(skill.hubId, newActive);
+ }
+
+ async function toggleFlow(flow) {
+ const newActive = !flow.active;
+ setFlows((prev) =>
+ prev.map((f) => (f.uuid === flow.uuid ? { ...f, active: newActive } : f))
+ );
+ await AgentFlows.toggleFlow(flow.uuid, newActive);
+ }
+
+ // Build list of all skill items for rendering/keyboard navigation
+ const items = useMemo(() => {
+ const list = [];
+ for (const [key, { title }] of Object.entries({
+ ...defaultSkills,
+ ...configurableSkills,
+ })) {
+ list.push({
+ id: key,
+ name: title,
+ enabled: isSkillEnabled(key),
+ onToggle: () => toggleSkill(key),
+ });
+ }
+ for (const skill of importedSkills) {
+ list.push({
+ id: skill.hubId,
+ name: skill.name,
+ enabled: skill.active,
+ onToggle: () => toggleImportedSkill(skill),
+ });
+ }
+ for (const flow of flows) {
+ list.push({
+ id: flow.uuid,
+ name: flow.name,
+ enabled: flow.active,
+ onToggle: () => toggleFlow(flow),
+ });
+ }
+ return list;
+ }, [disabledDefaults, enabledConfigurable, importedSkills, flows]);
+
+ useToolsMenuItems({
+ items,
+ highlightedIndex,
+ onSelect: agentSessionActive ? () => {} : (item) => item.onToggle(),
+ registerItemCount,
+ });
+
+ if (loading) return null;
+
+ return (
+ <>
+ {!agentSessionActive && (
+
+ {t("chat_window.use_agent_session_to_use_tools")}
+
+ )}
+ {items.map((item, index) => (
+
+ ))}
+
+
+
+
+ {t("chat_window.manage_agent_skills")}
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashCommandRow/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashCommandRow/index.jsx
new file mode 100644
index 00000000..e20266c8
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashCommandRow/index.jsx
@@ -0,0 +1,100 @@
+import { useState, useRef, useEffect } from "react";
+import { DotsThree } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+
+export default function SlashCommandRow({
+ command,
+ description,
+ onClick,
+ onEdit,
+ onPublish,
+ showMenu = false,
+ highlighted = false,
+}) {
+ const { t } = useTranslation();
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef(null);
+ const menuBtnRef = useRef(null);
+
+ useEffect(() => {
+ if (!menuOpen) return;
+ function handleClickOutside(e) {
+ if (
+ menuRef.current &&
+ !menuRef.current.contains(e.target) &&
+ menuBtnRef.current &&
+ !menuBtnRef.current.contains(e.target)
+ ) {
+ setMenuOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [menuOpen]);
+
+ return (
+
+
+
+ {command}
+
+
+ {description}
+
+
+
+ {showMenu && (
+
+
{
+ e.stopPropagation();
+ setMenuOpen(!menuOpen);
+ }}
+ className="border-none cursor-pointer text-zinc-400 light:text-slate-500 p-0.5 hover:text-white light:hover:text-slate-900 rounded opacity-0 group-hover:opacity-100"
+ >
+
+
+
+ {menuOpen && (
+
+ {
+ e.stopPropagation();
+ setMenuOpen(false);
+ onEdit?.();
+ }}
+ >
+ {t("chat_window.edit")}
+
+ {
+ e.stopPropagation();
+ setMenuOpen(false);
+ onPublish?.();
+ }}
+ >
+ {t("chat_window.publish")}
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/AddPresetModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/AddPresetModal.jsx
similarity index 99%
rename from frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/AddPresetModal.jsx
rename to frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/AddPresetModal.jsx
index d699effc..6a0de986 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/AddPresetModal.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/AddPresetModal.jsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { X } from "@phosphor-icons/react";
import ModalWrapper from "@/components/ModalWrapper";
-import { CMD_REGEX } from ".";
+import { CMD_REGEX } from "./constants";
import { useTranslation } from "react-i18next";
export default function AddPresetModal({ isOpen, onClose, onSave }) {
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/EditPresetModal.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx
similarity index 99%
rename from frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/EditPresetModal.jsx
rename to frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx
index c50c3046..0f97f444 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SlashCommands/SlashPresets/EditPresetModal.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/EditPresetModal.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { X } from "@phosphor-icons/react";
import ModalWrapper from "@/components/ModalWrapper";
-import { CMD_REGEX } from ".";
+import { CMD_REGEX } from "./constants";
export default function EditPresetModal({
isOpen,
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/constants.js b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/constants.js
new file mode 100644
index 00000000..1ea23110
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/constants.js
@@ -0,0 +1 @@
+export const CMD_REGEX = /[^a-zA-Z0-9_-]/g;
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/index.jsx
new file mode 100644
index 00000000..ab10ad0b
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/index.jsx
@@ -0,0 +1,234 @@
+import { useState, useEffect, useMemo, useCallback } from "react";
+import { Plus } from "@phosphor-icons/react";
+import { useTranslation } from "react-i18next";
+import System from "@/models/system";
+import { useModal } from "@/hooks/useModal";
+import AddPresetModal from "./SlashPresets/AddPresetModal";
+import EditPresetModal from "./SlashPresets/EditPresetModal";
+import PublishEntityModal from "@/components/CommunityHub/PublishEntityModal";
+import showToast from "@/utils/toast";
+import { useIsAgentSessionActive } from "@/utils/chat/agent";
+import { PROMPT_INPUT_EVENT } from "@/components/WorkspaceChat/ChatContainer/PromptInput";
+import useToolsMenuItems from "../../useToolsMenuItems";
+import SlashCommandRow from "./SlashCommandRow";
+
+export default function SlashCommandsTab({
+ sendCommand,
+ setShowing,
+ promptRef,
+ highlightedIndex = -1,
+ registerItemCount,
+}) {
+ const { t } = useTranslation();
+ const isActiveAgentSession = useIsAgentSessionActive();
+ const {
+ isOpen: isAddModalOpen,
+ openModal: openAddModal,
+ closeModal: closeAddModal,
+ } = useModal();
+ const {
+ isOpen: isEditModalOpen,
+ openModal: openEditModal,
+ closeModal: closeEditModal,
+ } = useModal();
+ const {
+ isOpen: isPublishModalOpen,
+ openModal: openPublishModal,
+ closeModal: closePublishModal,
+ } = useModal();
+ const [presets, setPresets] = useState([]);
+ const [selectedPreset, setSelectedPreset] = useState(null);
+ const [presetToPublish, setPresetToPublish] = useState(null);
+
+ useEffect(() => {
+ fetchPresets();
+ }, []);
+
+ const fetchPresets = async () => {
+ const presets = await System.getSlashCommandPresets();
+ setPresets(presets);
+ };
+
+ // Build the list of selectable items for keyboard navigation and rendering
+ // Command names must stay as static English strings since the backend
+ // matches against exact "/reset" and "/exit" commands.
+ const items = useMemo(() => {
+ const builtIn = isActiveAgentSession
+ ? {
+ command: "/exit",
+ description: t("chat_window.preset_exit_description"),
+ autoSubmit: true,
+ }
+ : {
+ command: "/reset",
+ description: t("chat_window.preset_reset_description"),
+ autoSubmit: true,
+ };
+
+ return [
+ builtIn,
+ ...presets.map((preset) => ({
+ command: preset.command,
+ description: preset.description,
+ autoSubmit: false,
+ preset,
+ })),
+ ];
+ }, [isActiveAgentSession, presets]);
+
+ const handleUseCommand = useCallback(
+ (command, autoSubmit = false) => {
+ setShowing(false);
+
+ // Auto-submit commands (/reset, /exit) fire immediately
+ if (autoSubmit) {
+ sendCommand({ text: command, autoSubmit: true });
+ promptRef?.current?.focus();
+ return;
+ }
+
+ // Insert the command at the cursor, replacing a trailing "/" if present
+ const textarea = promptRef?.current;
+ if (!textarea) return;
+ const cursor = textarea.selectionStart;
+ const value = textarea.value;
+ const charBefore = cursor > 0 ? value[cursor - 1] : "";
+ const insertStart = charBefore === "/" ? cursor - 1 : cursor;
+ const newValue =
+ value.slice(0, insertStart) + command + value.slice(cursor);
+
+ window.dispatchEvent(
+ new CustomEvent(PROMPT_INPUT_EVENT, {
+ detail: { messageContent: newValue },
+ })
+ );
+ textarea.focus();
+ const newCursor = insertStart + command.length;
+ setTimeout(() => textarea.setSelectionRange(newCursor, newCursor), 0);
+ },
+ [sendCommand, setShowing, promptRef]
+ );
+
+ useToolsMenuItems({
+ items,
+ highlightedIndex,
+ onSelect: (item) => {
+ const text = item.preset ? `${item.command} ` : item.command;
+ handleUseCommand(text, item.autoSubmit);
+ },
+ registerItemCount,
+ });
+
+ const handleSavePreset = async (preset) => {
+ const { error } = await System.createSlashCommandPreset(preset);
+ if (error) {
+ showToast(error, "error");
+ return false;
+ }
+ fetchPresets();
+ closeAddModal();
+ return true;
+ };
+
+ const handleEditPreset = (preset) => {
+ setSelectedPreset(preset);
+ openEditModal();
+ };
+
+ const handleUpdatePreset = async (updatedPreset) => {
+ const { error } = await System.updateSlashCommandPreset(
+ updatedPreset.id,
+ updatedPreset
+ );
+ if (error) {
+ showToast(error, "error");
+ return;
+ }
+ fetchPresets();
+ closeEditModal();
+ setSelectedPreset(null);
+ };
+
+ const handleDeletePreset = async (presetId) => {
+ await System.deleteSlashCommandPreset(presetId);
+ fetchPresets();
+ closeEditModal();
+ setSelectedPreset(null);
+ };
+
+ const handlePublishPreset = (preset) => {
+ setPresetToPublish({
+ name: preset.command.slice(1),
+ description: preset.description,
+ command: preset.command,
+ prompt: preset.prompt,
+ });
+ openPublishModal();
+ };
+
+ return (
+ <>
+ {items.map((item, index) => (
+
+ handleUseCommand(
+ item.preset ? `${item.command} ` : item.command,
+ item.autoSubmit
+ )
+ }
+ onEdit={item.preset ? () => handleEditPreset(item.preset) : undefined}
+ onPublish={
+ item.preset ? () => handlePublishPreset(item.preset) : undefined
+ }
+ showMenu={!!item.preset}
+ highlighted={highlightedIndex === index}
+ />
+ ))}
+
+ {/* Add new */}
+ {!isActiveAgentSession && (
+
+
+
+ {t("chat_window.add_new")}
+
+
+ )}
+
+ {/* Modals */}
+
+ {selectedPreset && (
+ {
+ closeEditModal();
+ setSelectedPreset(null);
+ }}
+ onSave={handleUpdatePreset}
+ onDelete={handleDeletePreset}
+ preset={selectedPreset}
+ />
+ )}
+
+ >
+ );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx
new file mode 100644
index 00000000..a5146702
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx
@@ -0,0 +1,171 @@
+import { useState, useEffect, useCallback, useRef, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import useUser from "@/hooks/useUser";
+import AgentSkillsTab from "./Tabs/AgentSkills";
+import SlashCommandsTab from "./Tabs/SlashCommands";
+
+export const TOOLS_MENU_KEYBOARD_EVENT = "tools-menu-keyboard";
+function getTabs(t, user) {
+ const tabs = [
+ {
+ key: "slash-commands",
+ label: t("chat_window.slash_commands"),
+ component: SlashCommandsTab,
+ },
+ ];
+
+ // Only show agent skills tab for admins or when multiuser mode is off
+ const canSeeAgentSkills =
+ !user?.hasOwnProperty("role") || user.role === "admin";
+ if (canSeeAgentSkills) {
+ tabs.push({
+ key: "agent-skills",
+ label: t("chat_window.agent_skills"),
+ component: AgentSkillsTab,
+ });
+ }
+
+ return tabs;
+}
+
+/**
+ * @param {boolean} props.showing
+ * @param {function} props.setShowing
+ * @param {function} props.sendCommand
+ * @param {object} props.promptRef
+ * @param {boolean} [props.centered] - when true, popup opens below the input
+ */
+export default function ToolsMenu({
+ showing,
+ setShowing,
+ sendCommand,
+ promptRef,
+ centered = false,
+ highlightedIndexRef,
+}) {
+ const { t } = useTranslation();
+ const { user } = useUser();
+ const TABS = useMemo(() => getTabs(t, user), [t, user]);
+ const [activeTab, setActiveTab] = useState(TABS[0].key);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const itemCountRef = useRef(0);
+
+ // Always open to the slash commands
+ useEffect(() => {
+ if (showing) setActiveTab(TABS[0].key);
+ }, [showing]);
+
+ // Reset highlight when switching tabs or closing
+ useEffect(() => {
+ setHighlightedIndex(-1);
+ }, [activeTab, showing]);
+
+ // Keep the parent ref in sync so PromptInput can check it on Enter
+ useEffect(() => {
+ if (highlightedIndexRef) highlightedIndexRef.current = highlightedIndex;
+ }, [highlightedIndex]);
+
+ const registerItemCount = useCallback((count) => {
+ itemCountRef.current = count;
+ }, []);
+
+ useEffect(() => {
+ if (!showing) return;
+
+ function handleKeyboard(e) {
+ const { key } = e.detail;
+
+ if (key === "ArrowLeft" || key === "ArrowRight") {
+ const currentIdx = TABS.findIndex((tab) => tab.key === activeTab);
+ const nextIdx =
+ key === "ArrowLeft"
+ ? (currentIdx - 1 + TABS.length) % TABS.length
+ : (currentIdx + 1) % TABS.length;
+ setActiveTab(TABS[nextIdx].key);
+ return;
+ }
+
+ if (key === "ArrowUp" || key === "ArrowDown") {
+ const count = itemCountRef.current;
+ if (count === 0) return;
+ setHighlightedIndex((prev) => {
+ if (key === "ArrowDown") {
+ return prev < count - 1 ? prev + 1 : 0;
+ }
+ return prev > 0 ? prev - 1 : count - 1;
+ });
+ return;
+ }
+
+ // Enter is handled by the tab components via highlightedIndex
+ }
+
+ window.addEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleKeyboard);
+ return () =>
+ window.removeEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleKeyboard);
+ }, [showing, activeTab]);
+
+ if (!showing) return null;
+
+ const { component: ActiveTab } = TABS.find((tab) => tab.key === activeTab);
+
+ return (
+ <>
+ e.preventDefault()}
+ onClick={() => setShowing(false)}
+ />
+
{
+ // Prevents prompt textarea from losing focus when clicking inside the menu.
+ // Skip for portaled modals so their inputs can still receive focus.
+ if (e.currentTarget.contains(e.target)) e.preventDefault();
+ }}
+ className={`absolute left-2 right-2 md:left-14 md:right-auto md:w-[400px] z-50 bg-zinc-800 light:bg-white border border-zinc-700 light:border-slate-300 rounded-lg p-3 flex flex-col gap-2.5 shadow-lg overflow-hidden ${
+ centered
+ ? "top-full mt-2 max-h-[min(360px,calc(100dvh-25rem))]"
+ : "bottom-full mb-2 max-h-[min(360px,calc(100dvh-11rem))]"
+ }`}
+ >
+
+ {TABS.map((tab) => (
+ setActiveTab(tab.key)}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+
+ >
+ );
+}
+
+function TabButton({ active, onClick, children }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/useToolsMenuItems.js b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/useToolsMenuItems.js
new file mode 100644
index 00000000..d7c8d399
--- /dev/null
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/useToolsMenuItems.js
@@ -0,0 +1,32 @@
+import { useEffect } from "react";
+import { TOOLS_MENU_KEYBOARD_EVENT } from "./";
+
+/**
+ * Shared hook for ToolsMenu tabs that registers the item count
+ * for Up/Down navigation and handles Enter to select the highlighted item.
+ * @param {Array} items - the list of items rendered in the tab
+ * @param {number} highlightedIndex - currently highlighted index from parent
+ * @param {function} onSelect - called with the highlighted item on Enter
+ * @param {function} registerItemCount - callback to register total item count with parent
+ */
+export default function useToolsMenuItems({
+ items,
+ highlightedIndex,
+ onSelect,
+ registerItemCount,
+}) {
+ useEffect(() => {
+ registerItemCount?.(items.length);
+ }, [items.length, registerItemCount]);
+
+ useEffect(() => {
+ if (highlightedIndex < 0 || highlightedIndex >= items.length) return;
+ function handleEnter(e) {
+ if (e.detail.key !== "Enter") return;
+ onSelect(items[highlightedIndex]);
+ }
+ window.addEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleEnter);
+ return () =>
+ window.removeEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleEnter);
+ }, [highlightedIndex, items, onSelect]);
+}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
index 74690523..018c218b 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx
@@ -1,17 +1,7 @@
-import React, { useState, useRef, useEffect } from "react";
-import SlashCommandsButton, {
- SlashCommands,
- useSlashCommands,
-} from "./SlashCommands";
+import { useState, useRef, useEffect } from "react";
import debounce from "lodash.debounce";
-import { ArrowUp } from "@phosphor-icons/react";
+import { ArrowUp, At } from "@phosphor-icons/react";
import StopGenerationButton from "./StopGenerationButton";
-import AvailableAgentsButton, {
- AvailableAgents,
- useAvailableAgents,
-} from "./AgentMenu";
-import TextSizeButton from "./TextSizeMenu";
-import LLMSelectorAction from "./LLMSelector/action";
import SpeechToText from "./SpeechToText";
import { Tooltip } from "react-tooltip";
import AttachmentManager from "./Attachments";
@@ -25,6 +15,9 @@ import useTextSize from "@/hooks/useTextSize";
import { useTranslation } from "react-i18next";
import Appearance from "@/models/appearance";
import usePromptInputStorage from "@/hooks/usePromptInputStorage";
+import ToolsMenu, { TOOLS_MENU_KEYBOARD_EVENT } from "./ToolsMenu";
+import { useSearchParams } from "react-router-dom";
+import { useIsAgentSessionActive } from "@/utils/chat/agent";
export const PROMPT_INPUT_ID = "primary-prompt-input";
export const PROMPT_INPUT_EVENT = "set_prompt_input";
@@ -50,15 +43,18 @@ export default function PromptInput({
}) {
const { t } = useTranslation();
const { isDisabled } = useIsDisabled();
+ const agentSessionActive = useIsAgentSessionActive();
const [promptInput, setPromptInput] = useState("");
- const { showAgents, setShowAgents } = useAvailableAgents();
- const { showSlashCommand, setShowSlashCommand } = useSlashCommands();
+ const [showTools, setShowTools] = useState(false);
+ const autoOpenedToolsRef = useRef(false);
+ const toolsHighlightRef = useRef(-1);
const formRef = useRef(null);
const textareaRef = useRef(null);
const [_, setFocused] = useState(false);
const undoStack = useRef([]);
const redoStack = useRef([]);
const { textSizeClass } = useTextSize();
+ const [searchParams] = useSearchParams();
// Synchronizes prompt input value with localStorage, scoped to the current thread.
usePromptInputStorage({
@@ -66,6 +62,18 @@ export default function PromptInput({
setPromptInput,
});
+ /*
+ * @checklist-item
+ * If the URL has the agent param, open the agent menu for the user
+ * automatically when the component mounts.
+ */
+ useEffect(() => {
+ if (searchParams.get("action") === "set-agent-chat") {
+ sendCommand({ text: "@agent " });
+ textareaRef.current?.focus();
+ }
+ }, [textareaRef.current]);
+
/**
* To prevent too many re-renders we remotely listen for updates from the parent
* via an event cycle. Otherwise, using message as a prop leads to a re-render every
@@ -75,6 +83,8 @@ export default function PromptInput({
function handlePromptUpdate(e) {
const { messageContent, writeMode = "replace" } = e?.detail ?? {};
if (writeMode === "append") setPromptInput((prev) => prev + messageContent);
+ else if (writeMode === "prepend")
+ setPromptInput((prev) => messageContent + " " + prev);
else setPromptInput(messageContent ?? "");
}
@@ -106,7 +116,10 @@ export default function PromptInput({
const debouncedSaveState = debounce(saveCurrentState, 250);
function handleSubmit(e) {
+ // Ignore submits from portaled modals (slash command preset forms)
+ if (e.target !== e.currentTarget) return;
setFocused(false);
+ setShowTools(false);
submit(e);
}
@@ -115,31 +128,63 @@ export default function PromptInput({
textareaRef.current.style.height = "auto";
}
- function checkForSlash(e) {
- const input = e.target.value;
- if (input === "/") setShowSlashCommand(true);
- if (showSlashCommand) setShowSlashCommand(false);
- return;
- }
- const watchForSlash = debounce(checkForSlash, 300);
-
- function checkForAt(e) {
- const input = e.target.value;
- if (input === "@") return setShowAgents(true);
- if (showAgents) return setShowAgents(false);
- }
- const watchForAt = debounce(checkForAt, 300);
-
/**
* Capture enter key press to handle submission, redo, or undo
* via keyboard shortcuts
* @param {KeyboardEvent} event
*/
function captureEnterOrUndo(event) {
+ // Forward keyboard events to the ToolsMenu when open
+ if (showTools) {
+ if (
+ ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)
+ ) {
+ event.preventDefault();
+ window.dispatchEvent(
+ new CustomEvent(TOOLS_MENU_KEYBOARD_EVENT, {
+ detail: { key: event.key },
+ })
+ );
+ return;
+ }
+ // When an item is highlighted via arrow keys, Enter selects it.
+ // Otherwise, Enter falls through to submit the form normally.
+ if (event.key === "Enter" && toolsHighlightRef.current >= 0) {
+ event.preventDefault();
+ window.dispatchEvent(
+ new CustomEvent(TOOLS_MENU_KEYBOARD_EVENT, {
+ detail: { key: "Enter" },
+ })
+ );
+ return;
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setShowTools(false);
+ textareaRef.current?.focus();
+ return;
+ }
+ }
+
+ // "/" toggles the Tools menu only when the input is empty
+ if (
+ event.key === "/" &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ promptInput.trim() === ""
+ ) {
+ setShowTools((prev) => {
+ autoOpenedToolsRef.current = !prev;
+ return !prev;
+ });
+ return;
+ }
+
// Is simple enter key press w/o shift key
if (event.keyCode === 13 && !event.shiftKey) {
event.preventDefault();
if (isStreaming || isDisabled) return; // Prevent submission if streaming or disabled
+ setShowTools(false);
return submit(event);
}
@@ -252,10 +297,15 @@ export default function PromptInput({
function handleChange(e) {
debouncedSaveState(-1);
- watchForSlash(e);
- watchForAt(e);
adjustTextArea(e);
- setPromptInput(e.target.value);
+ const value = e.target.value;
+ setPromptInput(value);
+
+ // Auto-dismiss the tools menu when the "/" that opened it is modified
+ if (autoOpenedToolsRef.current && showTools && value !== "/") {
+ setShowTools(false);
+ autoOpenedToolsRef.current = false;
+ }
}
return (
@@ -263,23 +313,9 @@ export default function PromptInput({
className={
centered
? "w-full relative flex justify-center items-center"
- : "w-full fixed md:absolute bottom-0 left-0 z-10 md:z-0 flex justify-center items-center pwa:pb-5"
+ : "w-full fixed md:absolute bottom-0 left-0 z-10 flex justify-center items-center pwa:pb-5"
}
>
-
-