Skip to content

feat(dashboard): add slash-command framework with /steer#1972

Open
Automata-intelligentsia wants to merge 1 commit into
Runfusion:mainfrom
Automata-intelligentsia:fux-015-slash-command-steer
Open

feat(dashboard): add slash-command framework with /steer#1972
Automata-intelligentsia wants to merge 1 commit into
Runfusion:mainfrom
Automata-intelligentsia:fux-015-slash-command-steer

Conversation

@Automata-intelligentsia

@Automata-intelligentsia Automata-intelligentsia commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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.
  • `ChatView.tsx': integrates the command menu and dispatch path.
  • TaskPlannerChatTab.tsx: wires /steer for planner chats.
  • Tests for command matching, ChatView integration, and TaskPlannerChatTab behavior.

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

    • Added slash-command support in chat, including a command menu and keyboard navigation.
    • You can now type / to access available commands and insert them directly into the composer.
    • Chat now recognizes and runs supported commands when submitted, with clear success and error feedback.
  • Bug Fixes

    • Improved command matching so slash commands only trigger when entered at the start of a message.
    • Disabled commands are now clearly marked and won’t run when the required task state isn’t available.

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.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a generic slash-command framework (chat-commands.ts) with a /steer command backed by addSteeringComment. ChatView and TaskPlannerChatTab integrate command menus, keyboard navigation, and dispatch logic with agent-running checks, alongside corresponding test suites.

Changes

Slash Command Feature

Layer / File(s) Summary
Command registry and helpers
packages/dashboard/app/components/chat-commands.ts, packages/dashboard/app/components/__tests__/chat-commands.test.ts
Defines CommandContext, ChatCommand, CHAT_COMMANDS (with /steer), matchChatCommand, filterChatCommands, getSlashTriggerMatch, with unit tests.
ChatView command context and menu model
packages/dashboard/app/components/ChatView.tsx
Adds ChatCommandContext/SkillMenuEntry types, chatCommandContext prop, and unified command+skill menu entries via shared slash-trigger matching.
ChatView dispatch, selection, and rendering
packages/dashboard/app/components/ChatView.tsx
handleSend dispatches matched commands with agent-running checks and toasts; handleCommandSelect and keyboard navigation operate across mixed entries; dropdown rendering updated for commands and skills.
ChatView slash-command tests
packages/dashboard/app/components/__tests__/ChatView.chat-commands.test.tsx
Covers /steer visibility, filtering, selection, submission dispatch, disabled-agent state, and error handling.
TaskPlannerChatTab command menu and dispatch
packages/dashboard/app/components/TaskPlannerChatTab.tsx
Adds menu state, filteredCommands, dispatchSlashCommand, handleCommandMenuSelect, draft-change handling, keyboard navigation, and rendered command listbox with updated placeholder.
TaskPlannerChatTab slash-command tests
packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx
Adds addSteeringComment mock wiring and tests for /steer enablement, dispatch vs. normal send, warning toasts, and mid-message handling.

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a slash-command framework and the initial /steer command.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR lands a generic slash-command framework (chat-commands.ts) and wires the first command, /steer, into both ChatView and TaskPlannerChatTab. Commands share the existing "/" autocomplete trigger and skill-menu keyboard navigation; dispatch-on-submit routes matched commands to addSteeringComment instead of the normal chat-send path.

  • chat-commands.ts introduces a small, well-typed registry with matchChatCommand / filterChatCommands / getSlashTriggerMatch helpers; the single /steer entry delegates to the existing addSteeringComment API.
  • ChatView unifies commands and skills into a single skillMenuEntries list, gated by the optional chatCommandContext prop so non-task chat surfaces are unaffected.
  • Both ChatView and TaskPlannerChatTab share a race where clearComposerState()/setDraft(\"\") fires after a network round-trip, which can silently wipe text typed by the user in the interim.

Confidence Score: 3/5

The 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

Filename Overview
packages/dashboard/app/components/chat-commands.ts New registry file: clean interface + matching/filtering helpers. CHAT_COMMANDS is exported as a mutable array (should be readonly).
packages/dashboard/app/components/ChatView.tsx Integrates command registry into the unified "/" menu correctly. The fire-and-forget promise that calls clearComposerState() on success introduces a race: user input typed while the command is in flight gets silently wiped on resolution.
packages/dashboard/app/components/TaskPlannerChatTab.tsx Implements its own command-menu state. setDraft("") after the awaited command.run() has the same fire-and-forget race as ChatView; command menu aria-label reuses the "Skill suggestions" key.
packages/dashboard/app/components/tests/chat-commands.test.ts Comprehensive unit tests for registry, matchChatCommand, filterChatCommands, and run(). Covers edge cases including mid-message triggers, empty remainder, and error propagation.
packages/dashboard/app/components/tests/ChatView.chat-commands.test.tsx Integration tests cover all key ChatView command paths: menu visibility, filtering, trigger insertion, dispatch on submit, no-running-agent guard, and error recovery.
packages/dashboard/app/components/tests/TaskPlannerChatTab.test.tsx Adds /steer describe block with good coverage: disabled/enabled states, dispatch vs. normal send, no-agent guard, mid-message non-match.

Comments Outside Diff (1)

  1. packages/dashboard/app/components/TaskPlannerChatTab.tsx, line 366-385 (link)

    P1 Composer wipe race on command success (same issue as ChatView)

    setDraft("") is called after await command.run(...). Since sendMessage is invoked with void sendMessage(), the entire chain is fire-and-forget from the textarea's perspective. If the user begins typing before command.run resolves, setDraft("") on success will clear that new input. The fix is identical to ChatView: clear the draft immediately on Enter (before awaiting run), then omit the setDraft("") in the success path.

Reviews (1): Last reviewed commit: "feat(dashboard): add slash-command frame..." | Re-trigger Greptile

Comment on lines 1507 to 1541
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines 1055 to +1063
)}
</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")}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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[] = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Comment on lines +1 to +10
/**
* 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Attachments are silently dropped when dispatching a slash command.

files (from pendingAttachments) 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 clears pendingAttachments without 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

📥 Commits

Reviewing files that changed from the base of the PR and between f10c39f and 2aaf98d.

📒 Files selected for processing (6)
  • packages/dashboard/app/components/ChatView.tsx
  • packages/dashboard/app/components/TaskPlannerChatTab.tsx
  • packages/dashboard/app/components/__tests__/ChatView.chat-commands.test.tsx
  • packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx
  • packages/dashboard/app/components/__tests__/chat-commands.test.ts
  • packages/dashboard/app/components/chat-commands.ts

Comment on lines +1058 to +1090
{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>
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
{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”.

@gsxdsm

gsxdsm commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Good addition thanks! Please address greptile feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants