From 2aaf98d4ad3b2eb49cf44291dfd2ac9a020f6de4 Mon Sep 17 00:00:00 2001 From: ddonaldson130 Date: Thu, 9 Jul 2026 03:25:58 -0400 Subject: [PATCH] feat(dashboard): add slash-command framework with /steer (FUX-015) Adds a chat command registry and the /steer command for task-bound chats. Commands are triggered by '/' and dispatch through a common match/dispatch path alongside existing skills autocomplete. --- .../dashboard/app/components/ChatView.tsx | 215 +++++++++++---- .../app/components/TaskPlannerChatTab.tsx | 153 ++++++++++- .../__tests__/ChatView.chat-commands.test.tsx | 245 ++++++++++++++++++ .../__tests__/TaskPlannerChatTab.test.tsx | 76 +++++- .../__tests__/chat-commands.test.ts | 103 ++++++++ .../dashboard/app/components/chat-commands.ts | 142 ++++++++++ 6 files changed, 885 insertions(+), 49 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/ChatView.chat-commands.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/chat-commands.test.ts create mode 100644 packages/dashboard/app/components/chat-commands.ts diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 10870b53b..ebbc67632 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -51,12 +51,40 @@ import { StandardStreamingMessage, formatModelTag, } from "./StandardChatSurface"; +import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, getSlashTriggerMatch, type ChatCommand } from "./chat-commands"; + +/** + * Optional task-bound context that enables the "/" command registry (e.g. + * `/steer`) in a ChatView instance. When omitted (the default for the + * general, non-task-bound Chat surface), the command registry contributes + * nothing to the "/" menu and dispatch-on-submit is a no-op — skills + * autocomplete behaves exactly as before. + */ +export interface ChatCommandContext { + taskId: string; + projectId?: string; + /** Whether the bound task currently has a running/active agent. `/steer` is only dispatchable when true. */ + agentRunning: boolean; +} + +/** + * A single entry in the generalized "/" menu — either a registered command + * (e.g. `/steer`) or a discovered skill. Both kinds share one highlighted + * index / keyboard-nav path; only their selection behavior differs (a + * command is inserted as trigger text or dispatched later on submit, a + * skill is always inserted as a `/skill:` text token). + */ +export type SkillMenuEntry = + | { kind: "command"; command: ChatCommand; disabled: boolean } + | { kind: "skill"; skill: DiscoveredSkill }; export interface ChatViewProps { projectId?: string; addToast: (msg: string, type?: "success" | "error" | "warning") => void; experimentalFeatures?: Record; floating?: boolean; + /** Enables the "/" command registry (e.g. `/steer`) for this composer instance. See {@link ChatCommandContext}. */ + chatCommandContext?: ChatCommandContext; /* FNXC:RightDockChat 2026-06-27-23:12: The right dock can host ChatView in a 360px sidebar while the browser viewport remains desktop-sized. Let dock callers force the same narrow list/detail layout used by mobile/resized floating chat without passing floating chrome callbacks. @@ -162,21 +190,13 @@ const ALLOWED_ATTACHMENT_TYPES = [ "text/x-log", ]; -function getSkillTriggerMatch(value: string): { filter: string; start: number; end: number } | null { - const triggerMatch = /(^|[\s])\/([^\s]*)$/.exec(value); - if (!triggerMatch) { - return null; - } - - const prefix = triggerMatch[1] ?? ""; - const filter = triggerMatch[2] ?? ""; - const start = triggerMatch.index + prefix.length; - return { - filter, - start, - end: value.length, - }; -} +/** + * ChatView's local name for the shared slash-trigger matcher used by both + * skill autocomplete and the command registry (see chat-commands.ts's + * `getSlashTriggerMatch` doc comment: this alias exists so there is exactly + * one implementation of the trigger regex in the dashboard package). + */ +const getSkillTriggerMatch = getSlashTriggerMatch; function getMentionTriggerMatch( value: string, @@ -449,7 +469,7 @@ interface RoomContext { memberIds: ReadonlySet; } -export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onMinimize, onClose }: ChatViewProps) { +export function ChatView({ projectId, addToast, floating = false, compactLayout = false, onPopOut, onMaximize, onMinimize, onClose, chatCommandContext }: ChatViewProps) { const { t } = useTranslation("app"); useEffect(() => { recordResumeEvent({ @@ -768,6 +788,24 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout return matchingSkills.slice(0, 10); }, [discoveredSkills, skillFilter]); + // Commands only contribute to the "/" menu when this ChatView instance is + // bound to a task (chatCommandContext provided) — the general, non-task-bound + // Chat surface never shows/dispatches them, so its skill-only behavior is unchanged. + const filteredCommands = useMemo(() => { + if (!chatCommandContext) return [] as ChatCommand[]; + return filterChatCommands(skillFilter, CHAT_COMMANDS); + }, [chatCommandContext, skillFilter]); + + const skillMenuEntries = useMemo(() => { + const commandEntries: SkillMenuEntry[] = filteredCommands.map((command) => ({ + kind: "command", + command, + disabled: !chatCommandContext?.agentRunning, + })); + const skillEntries: SkillMenuEntry[] = filteredSkills.map((skill) => ({ kind: "skill", skill })); + return [...commandEntries, ...skillEntries]; + }, [filteredCommands, filteredSkills, chatCommandContext]); + const mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]); const roomContext = useMemo(() => { @@ -1469,6 +1507,36 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const files = pendingAttachments.map((attachment) => attachment.file); if ((!trimmed && files.length === 0) || !activeSession) return; + if (chatCommandContext) { + const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS); + if (commandMatch) { + if (!chatCommandContext.agentRunning) { + // Do not silently fall back to a normal chat message: /steer with no + // running agent is a no-op with feedback, not a plain send. + addToast(t("chat.commandNoRunningAgent", "No running agent to steer"), "warning"); + return; + } + + void commandMatch.command + .run({ + taskId: chatCommandContext.taskId, + projectId: chatCommandContext.projectId, + remainder: commandMatch.remainder, + }) + .then(() => { + clearComposerState(); + addToast(t("chat.commandSteerSuccess", "Sent to the running agent"), "success"); + }) + .catch((error: unknown) => { + const message = error instanceof Error && error.message.trim() + ? error.message + : t("chat.commandSteerFailed", "Failed to send to the running agent"); + addToast(message, "error"); + }); + return; + } + } + if (trimmed === "/clear" || trimmed === "/new") { clearComposerState(); clearPendingMessage(); @@ -1495,6 +1563,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout createSession, addToast, sendMessage, + chatCommandContext, + t, ]); @@ -1609,6 +1679,39 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout [resizeComposer], ); + const handleCommandSelect = useCallback( + (command: ChatCommand, disabled: boolean) => { + if (disabled) { + addToast(t("chat.commandNoRunningAgent", "No running agent to steer"), "warning"); + return; + } + + setMessageInput((currentInput) => { + const triggerMatch = getSkillTriggerMatch(currentInput); + if (!triggerMatch) { + return currentInput; + } + + const replacement = `${command.trigger} `; + const nextInput = + currentInput.slice(0, triggerMatch.start) + replacement + currentInput.slice(triggerMatch.end); + + window.requestAnimationFrame(() => { + if (!inputRef.current) return; + resizeComposer(inputRef.current); + inputRef.current.focus(); + }); + + return nextInput; + }); + + setShowSkillMenu(false); + setSkillFilter(""); + setHighlightedSkillIndex(0); + }, + [resizeComposer, addToast, t], + ); + const handleMentionSelect = useCallback( (agent: Agent) => { const textarea = inputRef.current; @@ -1720,27 +1823,29 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout if (showSkillMenu && e.key === "ArrowDown") { e.preventDefault(); - if (filteredSkills.length > 0) { - setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length); + if (skillMenuEntries.length > 0) { + setHighlightedSkillIndex((prev) => (prev + 1) % skillMenuEntries.length); } return; } if (showSkillMenu && e.key === "ArrowUp") { e.preventDefault(); - if (filteredSkills.length > 0) { + if (skillMenuEntries.length > 0) { setHighlightedSkillIndex((prev) => - prev === 0 ? filteredSkills.length - 1 : prev - 1, + prev === 0 ? skillMenuEntries.length - 1 : prev - 1, ); } return; } - if (showSkillMenu && (e.key === "Enter" || e.key === "Tab") && filteredSkills.length > 0) { + if (showSkillMenu && (e.key === "Enter" || e.key === "Tab") && skillMenuEntries.length > 0) { e.preventDefault(); - const skillToSelect = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0]; - if (skillToSelect) { - handleSkillSelect(skillToSelect); + const entryToSelect = skillMenuEntries[highlightedSkillIndex] ?? skillMenuEntries[0]; + if (entryToSelect?.kind === "skill") { + handleSkillSelect(entryToSelect.skill); + } else if (entryToSelect?.kind === "command") { + handleCommandSelect(entryToSelect.command, entryToSelect.disabled); } return; } @@ -1762,9 +1867,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout mentionHighlightIndex, handleMentionSelect, showSkillMenu, - filteredSkills, + skillMenuEntries, highlightedSkillIndex, handleSkillSelect, + handleCommandSelect, handleSendDispatch, fileMention, insertHashMention, @@ -2420,30 +2526,51 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout /> {showSkillMenu && (
- {skillsLoading ? ( + {skillsLoading && filteredCommands.length === 0 ? (
{t("chat.loadingSkills", "Loading skills…")}
- ) : filteredSkills.length === 0 ? ( + ) : skillMenuEntries.length === 0 ? (
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
) : ( - filteredSkills.map((skill, index) => ( - - )) + skillMenuEntries.map((entry, index) => + entry.kind === "command" ? ( + + ) : ( + + ), + ) )}
)} diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index f941e2b10..28f733c07 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -10,6 +10,7 @@ import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/par import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { ProviderIcon } from "./ProviderIcon"; import { StandardChatActionButton, StandardChatMessageItem, StandardStreamingMessage, formatModelTag } from "./StandardChatSurface"; +import { CHAT_COMMANDS, filterChatCommands, getSlashTriggerMatch, matchChatCommand, type ChatCommand } from "./chat-commands"; import "./TaskPlannerChatTab.css"; interface TaskPlannerChatTabProps { @@ -302,6 +303,9 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); + const [showCommandMenu, setShowCommandMenu] = useState(false); + const [commandFilter, setCommandFilter] = useState(""); + const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0); const [streamingThinking, setStreamingThinking] = useState(""); const [composerState, setComposerState] = useState("idle"); const composerStateRef = useRef("idle"); @@ -331,6 +335,22 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, }, [planningModelId, planningModelProvider]); const plannerChatScopeKey = `${task.id}\u0000${projectId ?? ""}\u0000${planningModelProvider ?? ""}\u0000${planningModelId ?? ""}`; + /* + * FNXC:TaskPlannerChatSlashCommands 2026-07-08-00:00: + * /steer is only dispatchable when this task's bound agent is actively + * running (task.column === "in-progress"), mirroring how TaskChatTab gates + * its own done-task affordance on task.column. Any other state (todo, + * in-review, done, archived, triage) shows the command in the menu but + * disabled with a hint instead of hiding it outright, and dispatch itself + * is refused with the same hint rather than silently sending plain chat. + */ + const agentRunning = task.column === "in-progress"; + const filteredCommands = useMemo(() => filterChatCommands(commandFilter, CHAT_COMMANDS), [commandFilter]); + + useEffect(() => { + setHighlightedCommandIndex(0); + }, [commandFilter]); + const applyStreamingSnapshot = useCallback((resolvedSessionId: string, text: string, thinking: string, toolCalls: ToolCallInfo[]) => { setStreamingThinking(thinking); setMessages((current) => { @@ -673,7 +693,68 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, await refreshTaskAfterEdit(hadDiscardedSideEffect); }, [messages, projectId, refreshMessagesForSession, refreshTaskAfterEdit, sendMessageContent, sessionId, t]); - const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]); + const dispatchSlashCommand = useCallback(async (command: ChatCommand, remainder: string) => { + if (!agentRunning) { + // Do not silently fall back to a normal chat message: /steer with no + // running agent is a no-op with feedback, not a plain send. + addToastRef.current(t("taskDetail.plannerChat.commandNoRunningAgent", "No running agent to steer"), "warning"); + return; + } + + try { + await command.run({ taskId: task.id, projectId, remainder }); + setDraft(""); + // Reuse the existing steering-refresh path (same toast + task refresh already + // used by the tool-call-driven steering flow above) instead of a second, + // divergent success toast for the same underlying action. + await refreshTaskAfterSteering(); + } catch (err) { + const message = getErrorMessage(err) || t("taskDetail.plannerChat.commandSteerFailed", "Failed to send to the running agent"); + addToastRef.current(message, "error"); + } + }, [agentRunning, projectId, refreshTaskAfterSteering, t, task.id]); + + const handleCommandMenuSelect = useCallback((command: ChatCommand) => { + if (!agentRunning) { + addToastRef.current(t("taskDetail.plannerChat.commandNoRunningAgent", "No running agent to steer"), "warning"); + return; + } + + setDraft((current) => { + const triggerMatch = getSlashTriggerMatch(current); + if (!triggerMatch) return current; + const replacement = `${command.trigger} `; + return current.slice(0, triggerMatch.start) + replacement + current.slice(triggerMatch.end); + }); + + setShowCommandMenu(false); + setCommandFilter(""); + setHighlightedCommandIndex(0); + }, [agentRunning, t]); + + const sendMessage = useCallback(() => { + const trimmed = draft.trim(); + const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS); + if (commandMatch) { + setShowCommandMenu(false); + return dispatchSlashCommand(commandMatch.command, commandMatch.remainder); + } + return sendMessageContent(draft); + }, [draft, dispatchSlashCommand, sendMessageContent]); + + const handleDraftChange = useCallback((event: React.ChangeEvent) => { + const nextValue = event.target.value; + setDraft(nextValue); + + const triggerMatch = getSlashTriggerMatch(nextValue); + if (triggerMatch) { + setShowCommandMenu(true); + setCommandFilter(triggerMatch.filter); + } else { + setShowCommandMenu(false); + setCommandFilter(""); + } + }, []); const stopPlannerStreaming = useCallback(() => { streamRequestRef.current += 1; @@ -686,10 +767,41 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, }, []); const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + if (showCommandMenu && event.key === "ArrowDown") { + event.preventDefault(); + if (filteredCommands.length > 0) { + setHighlightedCommandIndex((prev) => (prev + 1) % filteredCommands.length); + } + return; + } + + if (showCommandMenu && event.key === "ArrowUp") { + event.preventDefault(); + if (filteredCommands.length > 0) { + setHighlightedCommandIndex((prev) => (prev === 0 ? filteredCommands.length - 1 : prev - 1)); + } + return; + } + + if (showCommandMenu && (event.key === "Enter" || event.key === "Tab") && !event.shiftKey && filteredCommands.length > 0) { + event.preventDefault(); + const commandToSelect = filteredCommands[highlightedCommandIndex] ?? filteredCommands[0]; + if (commandToSelect) { + handleCommandMenuSelect(commandToSelect); + } + return; + } + + if (showCommandMenu && event.key === "Escape") { + event.preventDefault(); + setShowCommandMenu(false); + return; + } + if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); void sendMessage(); - }, [sendMessage]); + }, [showCommandMenu, filteredCommands, highlightedCommandIndex, handleCommandMenuSelect, sendMessage]); const canSend = draft.trim().length > 0 && composerState !== "sending"; const showEmptyState = historyLoaded && !loading && !error && messages.length === 0; @@ -943,13 +1055,46 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, )} + {showCommandMenu && ( +
+ {filteredCommands.length === 0 ? ( +
{t("chat.noSkillsFound", "No skills found")}
+ ) : ( + filteredCommands.map((command, index) => ( + + )) + )} +
+ )}