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.
This commit is contained in:
@@ -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:<name>` 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<string, boolean>;
|
||||
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,
|
||||
@@ -458,7 +478,7 @@ interface RoomContext {
|
||||
memberIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -777,6 +797,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<SkillMenuEntry[]>(() => {
|
||||
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<RoomContext | null>(() => {
|
||||
@@ -1478,6 +1516,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();
|
||||
@@ -1505,6 +1573,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
createSession,
|
||||
addToast,
|
||||
sendMessage,
|
||||
chatCommandContext,
|
||||
t,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1619,6 +1689,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;
|
||||
@@ -1730,27 +1833,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;
|
||||
}
|
||||
@@ -1772,9 +1877,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
mentionHighlightIndex,
|
||||
handleMentionSelect,
|
||||
showSkillMenu,
|
||||
filteredSkills,
|
||||
skillMenuEntries,
|
||||
highlightedSkillIndex,
|
||||
handleSkillSelect,
|
||||
handleCommandSelect,
|
||||
handleSendDispatch,
|
||||
fileMention,
|
||||
insertHashMention,
|
||||
@@ -2438,30 +2544,51 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
/>
|
||||
{showSkillMenu && (
|
||||
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
|
||||
{skillsLoading ? (
|
||||
{skillsLoading && filteredCommands.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
) : skillMenuEntries.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">
|
||||
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill, index) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedSkillIndex}
|
||||
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||||
onClick={() => handleSkillSelect(skill)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{skill.name}</span>
|
||||
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
|
||||
{skill.relativePath}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
skillMenuEntries.map((entry, index) =>
|
||||
entry.kind === "command" ? (
|
||||
<button
|
||||
key={`command-${entry.command.trigger}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedSkillIndex}
|
||||
aria-disabled={entry.disabled}
|
||||
className={`chat-skill-menu-item chat-command-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}${entry.disabled ? " chat-command-menu-item--disabled" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||||
onClick={() => handleCommandSelect(entry.command, entry.disabled)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{entry.command.trigger}</span>
|
||||
<span className="chat-skill-menu-item-description">
|
||||
{entry.disabled
|
||||
? t("chat.commandNoRunningAgentHint", "No running agent to steer")
|
||||
: entry.command.description}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={entry.skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedSkillIndex}
|
||||
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||||
onClick={() => handleSkillSelect(entry.skill)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{entry.skill.name}</span>
|
||||
<span className="chat-skill-menu-item-description" title={entry.skill.relativePath}>
|
||||
{entry.skill.relativePath}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
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<ComposerState>("idle");
|
||||
const composerStateRef = useRef<ComposerState>("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<HTMLTextAreaElement>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCommandMenu && (
|
||||
<div
|
||||
className="chat-skill-menu task-planner-chat-command-menu"
|
||||
data-testid="task-planner-chat-command-menu"
|
||||
role="listbox"
|
||||
aria-label={t("chat.skillSuggestions", "Skill suggestions")}
|
||||
>
|
||||
{filteredCommands.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">{t("chat.noSkillsFound", "No skills found")}</div>
|
||||
) : (
|
||||
filteredCommands.map((command, index) => (
|
||||
<button
|
||||
key={command.trigger}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedCommandIndex}
|
||||
aria-disabled={!agentRunning}
|
||||
className={`chat-skill-menu-item chat-command-menu-item${index === highlightedCommandIndex ? " chat-skill-menu-item--highlighted" : ""}${!agentRunning ? " chat-command-menu-item--disabled" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedCommandIndex(index)}
|
||||
onClick={() => handleCommandMenuSelect(command)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{command.trigger}</span>
|
||||
<span className="chat-skill-menu-item-description">
|
||||
{agentRunning
|
||||
? command.description
|
||||
: t("chat.commandNoRunningAgentHint", "No running agent to steer")}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="task-planner-chat-composer">
|
||||
<textarea
|
||||
className="input task-planner-chat-input"
|
||||
aria-label={t("taskDetail.plannerChat.inputLabel", "Message planner chat")}
|
||||
placeholder={t("taskDetail.plannerChat.placeholder", "Ask the planner about this task…")}
|
||||
placeholder={t("taskDetail.plannerChat.placeholder", "Ask the planner about this task… Type / for commands")}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onChange={handleDraftChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={composerState === "sending"}
|
||||
rows={1}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
FNXC:DashboardTests 2026-07-08-00:00:
|
||||
Sibling ChatView test file (kept separate from ChatView.core-interactions.test.tsx per the
|
||||
suite's split convention) covering the generalized "/" command registry: the /steer entry
|
||||
appears in the chat-skill-menu alongside skills, dispatch-on-submit calls addSteeringComment
|
||||
instead of a normal chat send, and the no-running-agent guard shows a hint instead of silently
|
||||
falling back to plain chat. vi.mock("../../api", ...) stays inline here (not in the shared
|
||||
harness) per the harness's TDZ note.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
import {
|
||||
renderWithAct,
|
||||
setupMockChat,
|
||||
activeSessionFixture,
|
||||
createMockSkill,
|
||||
mockFetchDiscoveredSkills,
|
||||
installChatViewEnv,
|
||||
} from "./ChatView.test-harness";
|
||||
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue({
|
||||
models: [],
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
addSteeringComment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { addSteeringComment } from "../../api";
|
||||
|
||||
const mockAddSteeringComment = vi.mocked(addSteeringComment);
|
||||
|
||||
installChatViewEnv();
|
||||
|
||||
const commandContext = { taskId: "TASK-1", projectId: "proj-123", agentRunning: true };
|
||||
|
||||
describe("ChatView slash-command dispatch (/steer)", () => {
|
||||
it("does not show the command menu entry when no chatCommandContext is provided", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "/");
|
||||
|
||||
expect(await screen.findByTestId("chat-skill-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByText("/steer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the /steer command in the menu alongside skills when a task context is bound", async () => {
|
||||
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
||||
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
|
||||
]);
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={vi.fn()} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "/");
|
||||
|
||||
expect(await screen.findByText("/steer")).toBeInTheDocument();
|
||||
expect(screen.getByText("review/pr")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters to just /steer when typing '/ste'", async () => {
|
||||
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
||||
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
|
||||
]);
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={vi.fn()} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "/ste");
|
||||
|
||||
expect(await screen.findByText("/steer")).toBeInTheDocument();
|
||||
expect(screen.queryByText("review/pr")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting /steer from the menu inserts the trigger as text, not a /skill: token", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={vi.fn()} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "/");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /steer/i }));
|
||||
|
||||
expect(textarea).toHaveValue("/steer ");
|
||||
expect(textarea).not.toHaveValue(expect.stringContaining("/skill:"));
|
||||
expect(screen.queryByTestId("chat-skill-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a skill still inserts its /skill: token unchanged when commands are also present", async () => {
|
||||
mockFetchDiscoveredSkills.mockResolvedValueOnce([
|
||||
createMockSkill({ id: "skill-review", name: "review/pr", relativePath: "skills/review/pr.md" }),
|
||||
]);
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={vi.fn()} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "/re");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /review\/pr/i }));
|
||||
|
||||
expect(textarea).toHaveValue("/skill:review/pr ");
|
||||
});
|
||||
|
||||
it("submitting '/steer do X' dispatches addSteeringComment and does not send a normal message", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
mockAddSteeringComment.mockResolvedValueOnce({ id: "TASK-1" } as any);
|
||||
const addToast = vi.fn();
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={addToast} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(mockAddSteeringComment).toHaveBeenCalledWith("TASK-1", "do X", "proj-123"));
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(textarea).toHaveValue(""));
|
||||
});
|
||||
|
||||
it("submitting a normal message still sends normally when a command context is bound", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
const addToast = vi.fn();
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={addToast} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
fireEvent.change(textarea, { target: { value: "hello there" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(sendMessage).toHaveBeenCalledWith("hello there", []));
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispatch when the trigger appears mid-message", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
const addToast = vi.fn();
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={addToast} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
fireEvent.change(textarea, { target: { value: "please /steer this" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(sendMessage).toHaveBeenCalledWith("please /steer this", []));
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submitting '/steer ...' with no running agent shows a hint and does not dispatch or send", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
const addToast = vi.fn();
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView
|
||||
projectId="proj-123"
|
||||
addToast={addToast}
|
||||
chatCommandContext={{ ...commandContext, agentRunning: false }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("No running agent"), "warning"));
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
expect(textarea).toHaveValue("/steer do X");
|
||||
});
|
||||
|
||||
it("shows a disabled hint in the menu when no running agent is bound", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
await renderWithAct(
|
||||
<ChatView
|
||||
projectId="proj-123"
|
||||
addToast={vi.fn()}
|
||||
chatCommandContext={{ ...commandContext, agentRunning: false }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "/");
|
||||
|
||||
const steerOption = await screen.findByRole("option", { name: /steer/i });
|
||||
expect(steerOption).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText(/no running agent/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves the composer text intact and shows an error toast when run() fails", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
mockAddSteeringComment.mockRejectedValueOnce(new Error("network down"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
await renderWithAct(
|
||||
<ChatView projectId="proj-123" addToast={addToast} chatCommandContext={commandContext} />,
|
||||
);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("network down", "error"));
|
||||
expect(textarea).toHaveValue("/steer do X");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
||||
|
||||
const taskPlannerChatCss = readFileSync(resolve(__dirname, "../TaskPlannerChatTab.css"), "utf8");
|
||||
|
||||
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockEditChatMessage, mockTranslations, mockT } = vi.hoisted(() => {
|
||||
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockEditChatMessage, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
|
||||
const translations = new Map<string, string>();
|
||||
return {
|
||||
mockEnsureTaskPlannerChatSession: vi.fn(),
|
||||
@@ -19,6 +19,7 @@ const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockF
|
||||
mockStreamChatResponse: vi.fn(),
|
||||
mockAttachChatStream: vi.fn(),
|
||||
mockEditChatMessage: vi.fn(),
|
||||
mockAddSteeringComment: vi.fn(),
|
||||
mockTranslations: translations,
|
||||
mockT: (key: string, fallback: string) => translations.get(key) ?? fallback,
|
||||
};
|
||||
@@ -42,6 +43,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
streamChatResponse: mockStreamChatResponse,
|
||||
attachChatStream: mockAttachChatStream,
|
||||
editChatMessage: mockEditChatMessage,
|
||||
addSteeringComment: mockAddSteeringComment,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -133,6 +135,7 @@ describe("TaskPlannerChatTab", () => {
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
mockEditChatMessage.mockResolvedValue({ retained: [] });
|
||||
mockAddSteeringComment.mockResolvedValue(makeTask("FN-7310"));
|
||||
});
|
||||
|
||||
it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => {
|
||||
@@ -1614,4 +1617,75 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(mockFetchTaskDetail).not.toHaveBeenCalledWith("FN-7310", expect.anything(), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("slash-command /steer", () => {
|
||||
it("shows /steer in the '/' menu, disabled with a hint, when the task's agent is not running", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "todo" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/" } });
|
||||
|
||||
const option = await screen.findByRole("option", { name: /steer/i });
|
||||
expect(option).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText(/no running agent/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables /steer in the menu when the task's agent is running (column === in-progress)", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/" } });
|
||||
|
||||
const option = await screen.findByRole("option", { name: /steer/i });
|
||||
expect(option).toHaveAttribute("aria-disabled", "false");
|
||||
});
|
||||
|
||||
it("submitting '/steer do X' on a running task calls addSteeringComment and does not start a planner-chat send", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }), projectId: "proj-1" });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(mockAddSteeringComment).toHaveBeenCalledWith("FN-7310", "do X", "proj-1"));
|
||||
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(textarea).toHaveValue(""));
|
||||
});
|
||||
|
||||
it("submitting a normal message still starts a planner-chat send when the task's agent is running", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "What is the status?" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await screen.findByText("What is the status?");
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submitting '/steer ...' with no running agent shows a hint and does not dispatch or send a message", async () => {
|
||||
const addToast = vi.fn();
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "todo" }), addToast });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "/steer do X" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("No running agent"), "warning"));
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
|
||||
expect(textarea).toHaveValue("/steer do X");
|
||||
});
|
||||
|
||||
it("does not dispatch when the trigger appears mid-message", async () => {
|
||||
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
|
||||
const textarea = await screen.findByLabelText("Message planner chat");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "please /steer this" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await screen.findByText("please /steer this");
|
||||
expect(mockAddSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
addSteeringComment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { addSteeringComment } from "../../api";
|
||||
import { CHAT_COMMANDS, matchChatCommand, filterChatCommands, type ChatCommand } from "../chat-commands";
|
||||
|
||||
const mockAddSteeringComment = vi.mocked(addSteeringComment);
|
||||
|
||||
describe("chat-commands registry", () => {
|
||||
beforeEach(() => {
|
||||
mockAddSteeringComment.mockReset();
|
||||
});
|
||||
|
||||
it("registers /steer as the first (and, today, only) command", () => {
|
||||
expect(CHAT_COMMANDS).toHaveLength(1);
|
||||
expect(CHAT_COMMANDS[0]).toMatchObject({
|
||||
trigger: "/steer",
|
||||
name: "steer",
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchChatCommand", () => {
|
||||
it("extracts the trigger and remainder for '/steer <text>'", () => {
|
||||
const match = matchChatCommand("/steer do X");
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.command.trigger).toBe("/steer");
|
||||
expect(match?.remainder).toBe("do X");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from the remainder", () => {
|
||||
const match = matchChatCommand("/steer do X with extra spaces ");
|
||||
expect(match?.remainder).toBe("do X with extra spaces");
|
||||
});
|
||||
|
||||
it("does not match when the trigger has no remainder text", () => {
|
||||
expect(matchChatCommand("/steer")).toBeNull();
|
||||
expect(matchChatCommand("/steer ")).toBeNull();
|
||||
expect(matchChatCommand("/steer ")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not match when the trigger appears mid-message", () => {
|
||||
expect(matchChatCommand("please /steer this task")).toBeNull();
|
||||
expect(matchChatCommand("hello /steer do X")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not match unrelated text or partial triggers", () => {
|
||||
expect(matchChatCommand("hello world")).toBeNull();
|
||||
expect(matchChatCommand("/steering do X")).toBeNull();
|
||||
expect(matchChatCommand("/ste do X")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports an injected command list for isolated testing", () => {
|
||||
const fakeCommand: ChatCommand = {
|
||||
trigger: "/retry",
|
||||
name: "retry",
|
||||
description: "test",
|
||||
run: vi.fn(),
|
||||
};
|
||||
const match = matchChatCommand("/retry now", [fakeCommand]);
|
||||
expect(match?.command).toBe(fakeCommand);
|
||||
expect(match?.remainder).toBe("now");
|
||||
expect(matchChatCommand("/steer now", [fakeCommand])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterChatCommands", () => {
|
||||
it("returns all commands when the filter is empty", () => {
|
||||
expect(filterChatCommands("")).toEqual(CHAT_COMMANDS);
|
||||
expect(filterChatCommands(" ")).toEqual(CHAT_COMMANDS);
|
||||
});
|
||||
|
||||
it("matches by partial trigger or name, case-insensitively", () => {
|
||||
expect(filterChatCommands("ste")).toEqual(CHAT_COMMANDS);
|
||||
expect(filterChatCommands("STE")).toEqual(CHAT_COMMANDS);
|
||||
expect(filterChatCommands("steer")).toEqual(CHAT_COMMANDS);
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing matches", () => {
|
||||
expect(filterChatCommands("zzz")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run()", () => {
|
||||
it("steer calls addSteeringComment with taskId, remainder text, and projectId", async () => {
|
||||
mockAddSteeringComment.mockResolvedValueOnce({ id: "TASK-1" } as any);
|
||||
const steerCommand = CHAT_COMMANDS.find((command) => command.name === "steer")!;
|
||||
|
||||
await steerCommand.run({ taskId: "TASK-1", projectId: "proj-123", remainder: "focus on the auth bug" });
|
||||
|
||||
expect(mockAddSteeringComment).toHaveBeenCalledWith("TASK-1", "focus on the auth bug", "proj-123");
|
||||
});
|
||||
|
||||
it("propagates rejection from addSteeringComment so callers can show an error", async () => {
|
||||
mockAddSteeringComment.mockRejectedValueOnce(new Error("network down"));
|
||||
const steerCommand = CHAT_COMMANDS.find((command) => command.name === "steer")!;
|
||||
|
||||
await expect(steerCommand.run({ taskId: "TASK-1", remainder: "text" })).rejects.toThrow("network down");
|
||||
});
|
||||
});
|
||||
});
|
||||
142
packages/dashboard/app/components/chat-commands.ts
Normal file
142
packages/dashboard/app/components/chat-commands.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Generic slash-command registry for chat composers.
|
||||
*
|
||||
* This module is deliberately small and additive: it defines the shape of a
|
||||
* dispatchable chat command and a starter registry containing exactly one
|
||||
* entry (`/steer`). Adding the next command (e.g. `/retry`) is a matter of
|
||||
* appending another `ChatCommand` entry — no changes to the matching/
|
||||
* filtering helpers below are required.
|
||||
*
|
||||
* The registry is consumed by composer hosts (e.g. ChatView.tsx,
|
||||
* TaskPlannerChatTab.tsx) which own:
|
||||
* - trigger detection / menu rendering (reusing the existing "/" trigger
|
||||
* scaffolding already used for skill autocomplete), and
|
||||
* - dispatch-on-submit (deciding whether the current composer text
|
||||
* matches a registered command and, if so, calling `run()` instead of
|
||||
* sending a normal chat message).
|
||||
*
|
||||
* `/steer` specifically requires a bound task with a running/active agent;
|
||||
* callers are responsible for gating dispatch on that (this module has no
|
||||
* opinion on task/agent state — it only matches text and executes actions).
|
||||
*/
|
||||
import { addSteeringComment } from "../api";
|
||||
|
||||
/** Context passed to a command's `run()` at dispatch time. */
|
||||
export interface CommandContext {
|
||||
/** The task this composer is bound to. */
|
||||
taskId: string;
|
||||
/** Optional project scope, forwarded to API calls exactly like existing composers do. */
|
||||
projectId?: string;
|
||||
/** Text following the trigger (and separating space), already trimmed. */
|
||||
remainder: string;
|
||||
}
|
||||
|
||||
export interface ChatCommand {
|
||||
/** Slash trigger, including the leading "/" (e.g. "/steer"). Matched at the start of composer text only — never mid-message. */
|
||||
trigger: string;
|
||||
/** Short identifier (without the leading "/") used for menu filtering. */
|
||||
name: string;
|
||||
/** Human-readable description shown in the "/" menu. */
|
||||
description: string;
|
||||
/** Executes the command's action. Resolves/rejects to let the caller show success/error feedback. */
|
||||
run(ctx: CommandContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command registry. `/steer` sends the remainder text to the task's
|
||||
* running agent via the existing steering endpoint (`addSteeringComment`),
|
||||
* exactly as the task-detail Activity composer already does — this is a new
|
||||
* entry point into that same, already-shipped mechanism, not a new backend
|
||||
* behavior.
|
||||
*/
|
||||
export const CHAT_COMMANDS: ChatCommand[] = [
|
||||
{
|
||||
trigger: "/steer",
|
||||
name: "steer",
|
||||
description: "Send context to the running agent without interrupting it",
|
||||
run(ctx: CommandContext) {
|
||||
return addSteeringComment(ctx.taskId, ctx.remainder, ctx.projectId);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface ChatCommandMatch {
|
||||
command: ChatCommand;
|
||||
remainder: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches composer text against a registered command trigger for
|
||||
* dispatch-on-submit.
|
||||
*
|
||||
* Only matches when:
|
||||
* - the trigger appears at the very start of the text (never mid-message —
|
||||
* e.g. "hey /steer this" does not match), and
|
||||
* - the trigger is followed by a space and at least one non-whitespace
|
||||
* remainder character (e.g. "/steer" alone with nothing after it is not
|
||||
* a dispatchable match and falls through to normal send behavior).
|
||||
*/
|
||||
export function matchChatCommand(text: string, commands: ChatCommand[] = CHAT_COMMANDS): ChatCommandMatch | null {
|
||||
for (const command of commands) {
|
||||
const prefix = `${command.trigger} `;
|
||||
if (!text.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const remainder = text.slice(prefix.length).trim();
|
||||
if (remainder.length === 0) {
|
||||
continue;
|
||||
}
|
||||
return { command, remainder };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the command registry using the same free-text filter already
|
||||
* derived from the "/" skill-trigger match (the text typed after the
|
||||
* slash), so the menu can show commands and skills side by side using one
|
||||
* shared filter value.
|
||||
*/
|
||||
export function filterChatCommands(filter: string, commands: ChatCommand[] = CHAT_COMMANDS): ChatCommand[] {
|
||||
const normalized = filter.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return commands;
|
||||
}
|
||||
return commands.filter((command) =>
|
||||
command.name.toLowerCase().includes(normalized)
|
||||
|| command.trigger.slice(1).toLowerCase().includes(normalized),
|
||||
);
|
||||
}
|
||||
|
||||
export interface SlashTriggerMatch {
|
||||
/** Text typed after the "/" (used to filter both skills and commands). */
|
||||
filter: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "/" trigger-detection helper reused by every chat composer that
|
||||
* wants the command/skill menu (ChatView.tsx's own `getSkillTriggerMatch` is
|
||||
* a thin alias of this function so there is exactly one implementation of
|
||||
* this regex in the dashboard package, not a second divergent copy).
|
||||
*
|
||||
* Matches a "/" at the start of the text or after whitespace, followed by
|
||||
* zero or more non-whitespace characters, anchored at the end of the value
|
||||
* (i.e. the trigger must be the token the caret is currently typing).
|
||||
*/
|
||||
export function getSlashTriggerMatch(value: string): SlashTriggerMatch | 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user