feat(web): add orchestrator command system in chat interface (#521)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #521.
This commit is contained in:
2026-02-26 03:39:00 +00:00
committed by jason.woltje
parent 859dcfc4b7
commit b110c469c4
6 changed files with 1119 additions and 8 deletions

View File

@@ -4,12 +4,13 @@
*/
import { createRef } from "react";
import { render } from "@testing-library/react";
import { render, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
import { Chat, type ChatRef } from "./Chat";
import * as useChatModule from "@/hooks/useChat";
import * as useWebSocketModule from "@/hooks/useWebSocket";
import * as authModule from "@/lib/auth/auth-context";
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
// Mock scrollIntoView (not available in JSDOM)
Element.prototype.scrollIntoView = vi.fn();
@@ -39,9 +40,14 @@ vi.mock("./ChatInput", () => ({
disabled: boolean;
inputRef: React.RefObject<HTMLTextAreaElement | null>;
}): React.ReactElement => (
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
Send
</button>
<>
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
Send
</button>
<button data-testid="chat-input-command" onClick={(): void => void onSend("/status")}>
Send Command
</button>
</>
),
DEFAULT_TEMPERATURE: 0.7,
DEFAULT_MAX_TOKENS: 4096,
@@ -54,11 +60,18 @@ vi.mock("./ChatInput", () => ({
],
}));
vi.mock("@/hooks/useOrchestratorCommands", () => ({
useOrchestratorCommands: vi.fn(),
}));
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule.useChat>;
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
typeof useWebSocketModule.useWebSocket
>;
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
typeof orchestratorModule.useOrchestratorCommands
>;
function createMockUseChatReturn(
overrides: Partial<useChatModule.UseChatReturn> = {}
@@ -107,6 +120,12 @@ describe("Chat", () => {
socket: null,
connectionError: null,
});
// Default: no commands intercepted
mockUseOrchestratorCommands.mockReturnValue({
isCommand: vi.fn().mockReturnValue(false),
executeCommand: vi.fn().mockResolvedValue(null),
});
});
afterEach(() => {
@@ -160,4 +179,105 @@ describe("Chat", () => {
});
});
});
describe("orchestrator command routing", () => {
it("routes command messages through orchestrator instead of LLM", async () => {
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
const mockSetMessages = vi.fn();
const mockExecuteCommand = vi.fn().mockResolvedValue({
id: "orch-123",
role: "assistant" as const,
content: "**Orchestrator Status**\n\n| Field | Value |\n|---|---|\n| Status | **Ready** |",
createdAt: new Date().toISOString(),
});
mockUseChat.mockReturnValue(
createMockUseChatReturn({
sendMessage: mockSendMessage,
setMessages: mockSetMessages,
})
);
mockUseOrchestratorCommands.mockReturnValue({
isCommand: (content: string) => content.trim().startsWith("/"),
executeCommand: mockExecuteCommand,
});
const { getByTestId } = render(<Chat />);
const commandButton = getByTestId("chat-input-command");
fireEvent.click(commandButton);
await waitFor(() => {
// executeCommand should have been called with the slash command
expect(mockExecuteCommand).toHaveBeenCalledWith("/status");
});
// sendMessage should NOT have been called
expect(mockSendMessage).not.toHaveBeenCalled();
// setMessages should have been called to add user and assistant messages
await waitFor(() => {
expect(mockSetMessages).toHaveBeenCalledTimes(2);
});
});
it("does not call orchestrator for regular messages", async () => {
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
const mockExecuteCommand = vi.fn().mockResolvedValue(null);
mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
mockUseOrchestratorCommands.mockReturnValue({
isCommand: vi.fn().mockReturnValue(false),
executeCommand: mockExecuteCommand,
});
const { getByTestId } = render(<Chat />);
fireEvent.click(getByTestId("chat-input"));
await waitFor(() => {
expect(mockSendMessage).toHaveBeenCalledWith("test message");
});
expect(mockExecuteCommand).not.toHaveBeenCalled();
});
it("still adds user message to chat for commands", async () => {
const mockSetMessages = vi.fn();
const mockExecuteCommand = vi.fn().mockResolvedValue({
id: "orch-456",
role: "assistant" as const,
content: "Help content",
createdAt: new Date().toISOString(),
});
mockUseChat.mockReturnValue(createMockUseChatReturn({ setMessages: mockSetMessages }));
mockUseOrchestratorCommands.mockReturnValue({
isCommand: (content: string) => content.trim().startsWith("/"),
executeCommand: mockExecuteCommand,
});
const { getByTestId } = render(<Chat />);
fireEvent.click(getByTestId("chat-input-command"));
await waitFor(() => {
expect(mockSetMessages).toHaveBeenCalled();
});
// First setMessages call should add the user message
const firstCall = mockSetMessages.mock.calls[0];
if (!firstCall) throw new Error("Expected setMessages to have been called");
const updater = firstCall[0] as (prev: useChatModule.Message[]) => useChatModule.Message[];
const result = updater([]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
role: "user",
content: "/status",
});
});
});
});

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
import { useAuth } from "@/lib/auth/auth-context";
import { useChat } from "@/hooks/useChat";
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
import { useWebSocket } from "@/hooks/useWebSocket";
import { MessageList } from "./MessageList";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
@@ -79,6 +80,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
abortStream,
loadConversation,
startNewConversation,
setMessages,
clearError,
} = useChat({
model: selectedModel,
@@ -89,6 +91,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
const { isCommand, executeCommand } = useOrchestratorCommands();
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
@@ -192,9 +196,27 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const handleSendMessage = useCallback(
async (content: string) => {
if (isCommand(content)) {
// Add user message immediately
const userMessage: Message = {
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
role: "user",
content: content.trim(),
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
// Execute orchestrator command
const result = await executeCommand(content);
if (result) {
setMessages((prev) => [...prev, result]);
}
return;
}
await sendMessage(content);
},
[sendMessage]
[isCommand, executeCommand, setMessages, sendMessage]
);
const handleSuggestionClick = useCallback((prompt: string): void => {

View File

@@ -1,9 +1,10 @@
/**
* @file ChatInput.test.tsx
* @description Tests for ChatInput: model selector, temperature/params, localStorage persistence
* @description Tests for ChatInput: model selector, temperature/params, localStorage persistence,
* and command autocomplete.
*/
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
ChatInput,
@@ -291,3 +292,195 @@ describe("ChatInput — send behavior", () => {
expect(onStop).toHaveBeenCalledOnce();
});
});
describe("ChatInput — command autocomplete", () => {
it("shows no autocomplete for regular text", () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "hello world" } });
});
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
it("shows autocomplete dropdown when user types /", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
});
it("shows all commands when only / is typed", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
const dropdown = screen.getByTestId("command-autocomplete");
expect(dropdown).toHaveTextContent("/status");
expect(dropdown).toHaveTextContent("/agents");
expect(dropdown).toHaveTextContent("/jobs");
expect(dropdown).toHaveTextContent("/pause");
expect(dropdown).toHaveTextContent("/resume");
expect(dropdown).toHaveTextContent("/help");
});
});
it("filters commands by typed prefix", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/ag" } });
});
await waitFor(() => {
const dropdown = screen.getByTestId("command-autocomplete");
expect(dropdown).toHaveTextContent("/agents");
expect(dropdown).not.toHaveTextContent("/status");
expect(dropdown).not.toHaveTextContent("/pause");
});
});
it("dismisses autocomplete on Escape key", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.keyDown(textarea, { key: "Escape" });
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
});
it("accepts first command on Tab key", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/stat" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.keyDown(textarea, { key: "Tab" });
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
expect((textarea as HTMLTextAreaElement).value).toBe("/status ");
});
it("navigates with ArrowDown key", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.keyDown(textarea, { key: "ArrowDown" });
});
await waitFor(() => {
const options = screen.getAllByRole("option");
// Second item should be selected after ArrowDown
expect(options[1]).toHaveAttribute("aria-selected", "true");
});
});
it("fills command when clicking a suggestion", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
// Click on /agents option
const options = screen.getAllByRole("option");
const agentsOption = options.find((o) => o.textContent.includes("/agents"));
if (!agentsOption) throw new Error("Could not find /agents option");
act(() => {
fireEvent.click(agentsOption);
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
expect((textarea as HTMLTextAreaElement).value).toBe("/agents ");
});
it("shows command descriptions", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
const dropdown = screen.getByTestId("command-autocomplete");
expect(dropdown).toHaveTextContent("Show orchestrator health");
expect(dropdown).toHaveTextContent("Pause the job queue");
});
});
it("hides autocomplete when input no longer starts with /", async () => {
render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
act(() => {
fireEvent.change(textarea, { target: { value: "/" } });
});
await waitFor(() => {
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
});
act(() => {
fireEvent.change(textarea, { target: { value: "" } });
});
await waitFor(() => {
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
});
});
});

View File

@@ -2,6 +2,7 @@
import type { KeyboardEvent, RefObject } from "react";
import { useCallback, useState, useEffect, useRef } from "react";
import { ORCHESTRATOR_COMMANDS } from "@/hooks/useOrchestratorCommands";
export const AVAILABLE_MODELS = [
{ id: "llama3.2", label: "Llama 3.2" },
@@ -94,6 +95,11 @@ export function ChatInput({
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [isParamsOpen, setIsParamsOpen] = useState(false);
// Command autocomplete state
const [commandSuggestions, setCommandSuggestions] = useState<typeof ORCHESTRATOR_COMMANDS>([]);
const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0);
const commandDropdownRef = useRef<HTMLDivElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const paramsDropdownRef = useRef<HTMLDivElement>(null);
@@ -147,6 +153,35 @@ export function ChatInput({
});
}, []);
// Update command autocomplete suggestions when message changes
useEffect(() => {
const trimmed = message.trimStart();
if (!trimmed.startsWith("/")) {
setCommandSuggestions([]);
setHighlightedCommandIndex(0);
return;
}
// If the input contains a space, a command has been completed — no suggestions
if (trimmed.includes(" ")) {
setCommandSuggestions([]);
setHighlightedCommandIndex(0);
return;
}
const typedCommand = trimmed.toLowerCase();
// Build flat list including aliases
const matches = ORCHESTRATOR_COMMANDS.filter((cmd) => {
if (cmd.name.startsWith(typedCommand)) return true;
if (cmd.aliases?.some((a) => a.startsWith(typedCommand))) return true;
return false;
});
setCommandSuggestions(matches);
setHighlightedCommandIndex(0);
}, [message]);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
@@ -156,6 +191,9 @@ export function ChatInput({
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
setIsParamsOpen(false);
}
if (commandDropdownRef.current && !commandDropdownRef.current.contains(e.target as Node)) {
setCommandSuggestions([]);
}
};
document.addEventListener("mousedown", handleClickOutside);
return (): void => {
@@ -174,8 +212,48 @@ export function ChatInput({
onStopStreaming?.();
}, [onStopStreaming]);
const acceptCommand = useCallback((cmdName: string): void => {
setMessage(cmdName + " ");
setCommandSuggestions([]);
setHighlightedCommandIndex(0);
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Command autocomplete navigation
if (commandSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedCommandIndex((prev) =>
prev < commandSuggestions.length - 1 ? prev + 1 : 0
);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedCommandIndex((prev) =>
prev > 0 ? prev - 1 : commandSuggestions.length - 1
);
return;
}
if (
e.key === "Tab" ||
(e.key === "Enter" && !e.shiftKey && commandSuggestions.length > 0)
) {
e.preventDefault();
const selected = commandSuggestions[highlightedCommandIndex];
if (selected) {
acceptCommand(selected.name);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setCommandSuggestions([]);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
@@ -185,7 +263,7 @@ export function ChatInput({
handleSubmit();
}
},
[handleSubmit]
[handleSubmit, commandSuggestions, highlightedCommandIndex, acceptCommand]
);
const handleModelSelect = useCallback(
@@ -462,6 +540,55 @@ export function ChatInput({
</div>
</div>
{/* Command Autocomplete Dropdown */}
{commandSuggestions.length > 0 && (
<div
ref={commandDropdownRef}
className="rounded-lg border shadow-lg"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
role="listbox"
aria-label="Command suggestions"
data-testid="command-autocomplete"
>
{commandSuggestions.map((cmd, idx) => (
<button
key={cmd.name}
role="option"
aria-selected={idx === highlightedCommandIndex}
onClick={() => {
acceptCommand(cmd.name);
}}
className="w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg"
style={{
backgroundColor:
idx === highlightedCommandIndex
? "rgb(var(--accent-primary) / 0.1)"
: "transparent",
color: "rgb(var(--text-primary))",
}}
>
<span
className="font-mono text-xs font-semibold"
style={{ color: "rgb(var(--accent-primary))" }}
>
{cmd.name}
</span>
{cmd.aliases && cmd.aliases.length > 0 && (
<span className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
({cmd.aliases.join(", ")})
</span>
)}
<span className="text-xs ml-auto" style={{ color: "rgb(var(--text-secondary))" }}>
{cmd.description}
</span>
</button>
))}
</div>
)}
{/* Input Container */}
<div
className="relative rounded-lg border transition-all duration-150"