feat(web): add orchestrator command system in chat interface (#521)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user