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:
ddonaldson130
2026-07-09 03:25:58 -04:00
committed by gsxdsm
parent 7c26c7e63a
commit 7c91824875
6 changed files with 885 additions and 49 deletions

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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");
});
});

View File

@@ -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();
});
});
});

View File

@@ -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");
});
});
});

View 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,
};
}