feat(dashboard): add slash-command framework with /steer#1972
feat(dashboard): add slash-command framework with /steer#1972Automata-intelligentsia wants to merge 1 commit into
Conversation
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.
📝 WalkthroughWalkthroughThis PR introduces a generic slash-command framework (chat-commands.ts) with a ChangesSlash Command Feature
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ChatView
participant chat-commands
participant addSteeringComment
User->>ChatView: types "/steer <text>" and submits
ChatView->>chat-commands: matchChatCommand(text, CHAT_COMMANDS)
chat-commands-->>ChatView: ChatCommandMatch (trigger, remainder)
alt agentRunning is false
ChatView->>User: show warning toast
else agentRunning is true
ChatView->>chat-commands: command.run({taskId, projectId, remainder})
chat-commands->>addSteeringComment: addSteeringComment(taskId, remainder, projectId)
addSteeringComment-->>chat-commands: success or rejection
alt success
chat-commands-->>ChatView: resolved
ChatView->>User: clear composer, show success toast
else failure
chat-commands-->>ChatView: rejected
ChatView->>User: show error toast
end
end
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR lands a generic slash-command framework (
Confidence Score: 3/5The core routing logic and guard conditions are correct, but the async composer-clear pattern in both host components can silently destroy user input on any slightly-delayed network call. Both ChatView and TaskPlannerChatTab clear the composer inside the resolution callback of a fire-and-forget async call. Any text the user types between pressing Enter and the API response being received is wiped without warning. The rest of the framework is well-constructed and thoroughly tested. ChatView.tsx (handleSendDispatch command branch) and TaskPlannerChatTab.tsx (dispatchSlashCommand) both need the composer clear moved to before the network call rather than in the success callback. Important Files Changed
|
| 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(); |
There was a problem hiding this comment.
Composer wipe race on command success
clearComposerState() is called inside the .then() of a fire-and-forget promise, so it runs after the network round-trip completes. Between dispatch and resolution the user can type new text into the composer; on a successful steer that new input is silently wiped. The fix is to clear the composer eagerly — immediately after matching the command but before calling run() — so the success handler only needs to show the toast and the user's post-dispatch typing is safe.
| )} | ||
| </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")} |
There was a problem hiding this comment.
Command menu reuses "Skill suggestions" aria-label
The aria-listbox for the command menu is labelled t("chat.skillSuggestions", "Skill suggestions"), which is the skills autocomplete key. Screen-reader users will hear "Skill suggestions" for a list that shows slash commands. A distinct label (e.g. "chat.commandSuggestions" / "Command suggestions") would avoid the mismatch.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| * entry point into that same, already-shipped mechanism, not a new backend | ||
| * behavior. | ||
| */ | ||
| export const CHAT_COMMANDS: ChatCommand[] = [ |
There was a problem hiding this comment.
CHAT_COMMANDS is exported as a mutable ChatCommand[]. Any consumer could accidentally push/splice the registry. Typing it readonly makes the intent explicit and prevents unintentional mutations at the callsites.
| export const CHAT_COMMANDS: ChatCommand[] = [ | |
| export const CHAT_COMMANDS: readonly ChatCommand[] = [ |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| /** | ||
| * 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, |
There was a problem hiding this comment.
Missing changeset for new user-facing feature
AGENTS.md requires a changeset when a change affects the published @runfusion/fusion package. The slash-command framework and /steer are new user-visible features in @fusion/dashboard, which bundles into @runfusion/fusion. The PR doesn't include a .changeset/ file; a minor bump with a summary and category: feature entry is expected.
Context Used: AGENTS.md (source)
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/dashboard/app/components/ChatView.tsx (1)
1505-1539: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winAttachments are silently dropped when dispatching a slash command.
files(frompendingAttachments) is computed but never used in the new command-dispatch branch. If a user stages attachments and then submits/steer ...,clearComposerState()on success revokes the attachment object URLs and clearspendingAttachmentswithout ever sending them — the user gets no indication their files were dropped.🐛 Proposed fix: warn and block dispatch when attachments are staged
if (chatCommandContext) { const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS); if (commandMatch) { + if (files.length > 0) { + addToast(t("chat.commandNoAttachments", "Attachments aren't supported with commands — remove them before sending"), "warning"); + return; + } if (!chatCommandContext.agentRunning) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/dashboard/app/components/ChatView.tsx` around lines 1505 - 1539, handleSend in ChatView.tsx is allowing slash-command dispatch even when pendingAttachments are staged, which causes those files to be cleared by clearComposerState without ever being sent. Update the command-dispatch path that uses matchChatCommand and commandMatch.command.run to either block command submission when files.length > 0 or surface a warning/toast before returning, so attachments are not silently dropped.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/dashboard/app/components/TaskPlannerChatTab.tsx`:
- Around line 1058-1090: The command menu in TaskPlannerChatTab is using
skill-specific copy even though it only renders commands. Update the listbox
copy in the showCommandMenu block to use command-specific labels for the
aria-label and empty state, and keep the wording consistent with
filteredCommands, handleCommandMenuSelect, and the chat command menu classes so
screen readers and users see “commands” instead of “skills”.
---
Outside diff comments:
In `@packages/dashboard/app/components/ChatView.tsx`:
- Around line 1505-1539: handleSend in ChatView.tsx is allowing slash-command
dispatch even when pendingAttachments are staged, which causes those files to be
cleared by clearComposerState without ever being sent. Update the
command-dispatch path that uses matchChatCommand and commandMatch.command.run to
either block command submission when files.length > 0 or surface a warning/toast
before returning, so attachments are not silently dropped.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 76593858-cb10-4ab7-8391-c9dc92b52316
📒 Files selected for processing (6)
packages/dashboard/app/components/ChatView.tsxpackages/dashboard/app/components/TaskPlannerChatTab.tsxpackages/dashboard/app/components/__tests__/ChatView.chat-commands.test.tsxpackages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsxpackages/dashboard/app/components/__tests__/chat-commands.test.tspackages/dashboard/app/components/chat-commands.ts
| {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> | ||
| )} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Command-only menu reuses mislabeled "skill" copy.
This listbox only ever renders filteredCommands (Planner Chat has no skills), yet its aria-label is "Skill suggestions" and the empty state says "No skills found". This is confusing for screen-reader users and anyone reading the empty-state text when a typed filter matches no command.
💬 Proposed fix: use command-specific copy
- role="listbox"
- aria-label={t("chat.skillSuggestions", "Skill suggestions")}
+ role="listbox"
+ aria-label={t("taskDetail.plannerChat.commandSuggestions", "Command suggestions")}
>
{filteredCommands.length === 0 ? (
- <div className="chat-skill-menu-empty">{t("chat.noSkillsFound", "No skills found")}</div>
+ <div className="chat-skill-menu-empty">{t("taskDetail.plannerChat.noCommandsFound", "No commands found")}</div>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {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> | |
| )} | |
| {showCommandMenu && ( | |
| <div | |
| className="chat-skill-menu task-planner-chat-command-menu" | |
| data-testid="task-planner-chat-command-menu" | |
| role="listbox" | |
| aria-label={t("taskDetail.plannerChat.commandSuggestions", "Command suggestions")} | |
| > | |
| {filteredCommands.length === 0 ? ( | |
| <div className="chat-skill-menu-empty">{t("taskDetail.plannerChat.noCommandsFound", "No commands 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> | |
| )} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/dashboard/app/components/TaskPlannerChatTab.tsx` around lines 1058 -
1090, The command menu in TaskPlannerChatTab is using skill-specific copy even
though it only renders commands. Update the listbox copy in the showCommandMenu
block to use command-specific labels for the aria-label and empty state, and
keep the wording consistent with filteredCommands, handleCommandMenuSelect, and
the chat command menu classes so screen readers and users see “commands” instead
of “skills”.
|
Good addition thanks! Please address greptile feedback |
Summary
Adds a general-purpose chat command registry and the first command,
/steer, for task-bound chats. Commands are triggered by typing '/' and share the existing skills autocomplete surface, while dispatch-on-submit routes registered commands to their handlers.What's included
chat-commands.ts: registry, matching, and slash-trigger detection.TaskPlannerChatTab.tsx: wires/steerfor planner chats.Notes
This is the FUX-015 feature. v0.57.0 did not include chat slash commands; this PR lands the framework plus the first command.
Summary by CodeRabbit
New Features
/to access available commands and insert them directly into the composer.Bug Fixes