Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 171 additions & 44 deletions packages/dashboard/app/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -449,7 +469,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({
Expand Down Expand Up @@ -768,6 +788,24 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
return matchingSkills.slice(0, 10);
}, [discoveredSkills, skillFilter]);

// Commands only contribute to the "/" menu when this ChatView instance is
// bound to a task (chatCommandContext provided) — the general, non-task-bound
// Chat surface never shows/dispatches them, so its skill-only behavior is unchanged.
const filteredCommands = useMemo(() => {
if (!chatCommandContext) return [] as ChatCommand[];
return filterChatCommands(skillFilter, CHAT_COMMANDS);
}, [chatCommandContext, skillFilter]);

const skillMenuEntries = useMemo<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>(() => {
Expand Down Expand Up @@ -1469,6 +1507,36 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const files = pendingAttachments.map((attachment) => attachment.file);
if ((!trimmed && files.length === 0) || !activeSession) return;

if (chatCommandContext) {
const commandMatch = matchChatCommand(trimmed, CHAT_COMMANDS);
if (commandMatch) {
if (!chatCommandContext.agentRunning) {
// Do not silently fall back to a normal chat message: /steer with no
// running agent is a no-op with feedback, not a plain send.
addToast(t("chat.commandNoRunningAgent", "No running agent to steer"), "warning");
return;
}

void commandMatch.command
.run({
taskId: chatCommandContext.taskId,
projectId: chatCommandContext.projectId,
remainder: commandMatch.remainder,
})
.then(() => {
clearComposerState();
addToast(t("chat.commandSteerSuccess", "Sent to the running agent"), "success");
})
.catch((error: unknown) => {
const message = error instanceof Error && error.message.trim()
? error.message
: t("chat.commandSteerFailed", "Failed to send to the running agent");
addToast(message, "error");
});
return;
}
}

if (trimmed === "/clear" || trimmed === "/new") {
clearComposerState();
Comment on lines 1507 to 1541

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.

clearPendingMessage();
Expand All @@ -1495,6 +1563,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
createSession,
addToast,
sendMessage,
chatCommandContext,
t,
]);


Expand Down Expand Up @@ -1609,6 +1679,39 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
[resizeComposer],
);

const handleCommandSelect = useCallback(
(command: ChatCommand, disabled: boolean) => {
if (disabled) {
addToast(t("chat.commandNoRunningAgent", "No running agent to steer"), "warning");
return;
}

setMessageInput((currentInput) => {
const triggerMatch = getSkillTriggerMatch(currentInput);
if (!triggerMatch) {
return currentInput;
}

const replacement = `${command.trigger} `;
const nextInput =
currentInput.slice(0, triggerMatch.start) + replacement + currentInput.slice(triggerMatch.end);

window.requestAnimationFrame(() => {
if (!inputRef.current) return;
resizeComposer(inputRef.current);
inputRef.current.focus();
});

return nextInput;
});

setShowSkillMenu(false);
setSkillFilter("");
setHighlightedSkillIndex(0);
},
[resizeComposer, addToast, t],
);

const handleMentionSelect = useCallback(
(agent: Agent) => {
const textarea = inputRef.current;
Expand Down Expand Up @@ -1720,27 +1823,29 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout

if (showSkillMenu && e.key === "ArrowDown") {
e.preventDefault();
if (filteredSkills.length > 0) {
setHighlightedSkillIndex((prev) => (prev + 1) % filteredSkills.length);
if (skillMenuEntries.length > 0) {
setHighlightedSkillIndex((prev) => (prev + 1) % skillMenuEntries.length);
}
return;
}

if (showSkillMenu && e.key === "ArrowUp") {
e.preventDefault();
if (filteredSkills.length > 0) {
if (skillMenuEntries.length > 0) {
setHighlightedSkillIndex((prev) =>
prev === 0 ? filteredSkills.length - 1 : prev - 1,
prev === 0 ? skillMenuEntries.length - 1 : prev - 1,
);
}
return;
}

if (showSkillMenu && (e.key === "Enter" || e.key === "Tab") && filteredSkills.length > 0) {
if (showSkillMenu && (e.key === "Enter" || e.key === "Tab") && skillMenuEntries.length > 0) {
e.preventDefault();
const skillToSelect = filteredSkills[highlightedSkillIndex] ?? filteredSkills[0];
if (skillToSelect) {
handleSkillSelect(skillToSelect);
const entryToSelect = skillMenuEntries[highlightedSkillIndex] ?? skillMenuEntries[0];
if (entryToSelect?.kind === "skill") {
handleSkillSelect(entryToSelect.skill);
} else if (entryToSelect?.kind === "command") {
handleCommandSelect(entryToSelect.command, entryToSelect.disabled);
}
return;
}
Expand All @@ -1762,9 +1867,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
mentionHighlightIndex,
handleMentionSelect,
showSkillMenu,
filteredSkills,
skillMenuEntries,
highlightedSkillIndex,
handleSkillSelect,
handleCommandSelect,
handleSendDispatch,
fileMention,
insertHashMention,
Expand Down Expand Up @@ -2420,30 +2526,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>
)}
Expand Down
Loading
Loading