Compare commits
2 Commits
a7fbc1ccc8
...
feat/ms19-
| Author | SHA1 | Date | |
|---|---|---|---|
| e41fedb3c2 | |||
| 5ba77d8952 |
@@ -18,12 +18,6 @@ COPY turbo.json ./
|
|||||||
# ======================
|
# ======================
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
|
||||||
# and OpenSSL for Prisma engine detection
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
python3 make g++ openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy all package.json files for workspace resolution
|
# Copy all package.json files for workspace resolution
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
@@ -31,11 +25,7 @@ COPY packages/config/package.json ./packages/config/
|
|||||||
COPY apps/api/package.json ./apps/api/
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
RUN pnpm install --frozen-lockfile
|
||||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
|
||||||
RUN pnpm install --frozen-lockfile \
|
|
||||||
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
|
|
||||||
&& npx node-gyp rebuild 2>&1 || true
|
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Builder stage
|
# Builder stage
|
||||||
@@ -68,11 +58,7 @@ FROM node:24-slim AS production
|
|||||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
||||||
|
|
||||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
# - openssl: Prisma engine detection requires libssl
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
# - No build tools needed here — native addons are compiled in the deps stage
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
|
||||||
&& chmod 755 /usr/local/bin/dumb-init \
|
&& chmod 755 /usr/local/bin/dumb-init \
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe("TerminalService", () => {
|
|||||||
let service: TerminalService;
|
let service: TerminalService;
|
||||||
let mockSocket: Socket;
|
let mockSocket: Socket;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Reset mock implementations
|
// Reset mock implementations
|
||||||
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
||||||
@@ -54,8 +54,6 @@ describe("TerminalService", () => {
|
|||||||
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
||||||
);
|
);
|
||||||
service = new TerminalService();
|
service = new TerminalService();
|
||||||
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
|
|
||||||
await service.onModuleInit();
|
|
||||||
mockSocket = createMockSocket();
|
mockSocket = createMockSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,19 +13,11 @@
|
|||||||
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import type { IPty } from "node-pty";
|
import * as pty from "node-pty";
|
||||||
import type { Socket } from "socket.io";
|
import type { Socket } from "socket.io";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
|
|
||||||
// if the native binary is missing. node-pty requires a compiled .node
|
|
||||||
// binary which may not be available in all Docker environments.
|
|
||||||
interface NodePtyModule {
|
|
||||||
spawn: (file: string, args: string[], options: Record<string, unknown>) => IPty;
|
|
||||||
}
|
|
||||||
let pty: NodePtyModule | null = null;
|
|
||||||
|
|
||||||
/** Maximum concurrent PTY sessions per workspace */
|
/** Maximum concurrent PTY sessions per workspace */
|
||||||
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
||||||
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
||||||
@@ -39,7 +31,7 @@ const DEFAULT_ROWS = 24;
|
|||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
pty: IPty;
|
pty: pty.IPty;
|
||||||
name?: string;
|
name?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
@@ -61,7 +53,7 @@ export interface SessionCreatedResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TerminalService implements OnModuleInit {
|
export class TerminalService {
|
||||||
private readonly logger = new Logger(TerminalService.name);
|
private readonly logger = new Logger(TerminalService.name);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,30 +66,13 @@ export class TerminalService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private readonly workspaceSessions = new Map<string, Set<string>>();
|
private readonly workspaceSessions = new Map<string, Set<string>>();
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
if (!pty) {
|
|
||||||
try {
|
|
||||||
pty = await import("node-pty");
|
|
||||||
this.logger.log("node-pty loaded successfully — terminal sessions available");
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(
|
|
||||||
"node-pty native module not available — terminal sessions will be disabled. " +
|
|
||||||
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new PTY session for the given workspace and socket.
|
* Create a new PTY session for the given workspace and socket.
|
||||||
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
||||||
*
|
*
|
||||||
* @throws Error if workspace session limit is exceeded or node-pty is unavailable
|
* @throws Error if workspace session limit is exceeded
|
||||||
*/
|
*/
|
||||||
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
||||||
if (!pty) {
|
|
||||||
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
|
|
||||||
}
|
|
||||||
const { workspaceId, name, cwd, socketId } = options;
|
const { workspaceId, name, cwd, socketId } = options;
|
||||||
const cols = options.cols ?? DEFAULT_COLS;
|
const cols = options.cols ?? DEFAULT_COLS;
|
||||||
const rows = options.rows ?? DEFAULT_ROWS;
|
const rows = options.rows ?? DEFAULT_ROWS;
|
||||||
|
|||||||
@@ -33,9 +33,6 @@
|
|||||||
"@tiptap/react": "^3.20.0",
|
"@tiptap/react": "^3.20.0",
|
||||||
"@tiptap/starter-kit": "^3.20.0",
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
|
||||||
"@xterm/xterm": "^6.0.0",
|
|
||||||
"@xyflow/react": "^12.5.3",
|
"@xyflow/react": "^12.5.3",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRef } from "react";
|
import { createRef } from "react";
|
||||||
import { render, fireEvent, waitFor } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
||||||
import { Chat, type ChatRef } from "./Chat";
|
import { Chat, type ChatRef } from "./Chat";
|
||||||
import * as useChatModule from "@/hooks/useChat";
|
import * as useChatModule from "@/hooks/useChat";
|
||||||
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
||||||
import * as authModule from "@/lib/auth/auth-context";
|
import * as authModule from "@/lib/auth/auth-context";
|
||||||
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
|
|
||||||
|
|
||||||
// Mock scrollIntoView (not available in JSDOM)
|
// Mock scrollIntoView (not available in JSDOM)
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
@@ -40,28 +39,10 @@ vi.mock("./ChatInput", () => ({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
}): React.ReactElement => (
|
}): React.ReactElement => (
|
||||||
<>
|
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
||||||
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
Send
|
||||||
Send
|
</button>
|
||||||
</button>
|
|
||||||
<button data-testid="chat-input-command" onClick={(): void => void onSend("/status")}>
|
|
||||||
Send Command
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
DEFAULT_TEMPERATURE: 0.7,
|
|
||||||
DEFAULT_MAX_TOKENS: 4096,
|
|
||||||
DEFAULT_MODEL: "llama3.2",
|
|
||||||
AVAILABLE_MODELS: [
|
|
||||||
{ id: "llama3.2", label: "Llama 3.2" },
|
|
||||||
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
|
||||||
{ id: "gpt-4o", label: "GPT-4o" },
|
|
||||||
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/useOrchestratorCommands", () => ({
|
|
||||||
useOrchestratorCommands: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
||||||
@@ -69,9 +50,6 @@ const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule
|
|||||||
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
||||||
typeof useWebSocketModule.useWebSocket
|
typeof useWebSocketModule.useWebSocket
|
||||||
>;
|
>;
|
||||||
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
|
|
||||||
typeof orchestratorModule.useOrchestratorCommands
|
|
||||||
>;
|
|
||||||
|
|
||||||
function createMockUseChatReturn(
|
function createMockUseChatReturn(
|
||||||
overrides: Partial<useChatModule.UseChatReturn> = {}
|
overrides: Partial<useChatModule.UseChatReturn> = {}
|
||||||
@@ -120,12 +98,6 @@ describe("Chat", () => {
|
|||||||
socket: null,
|
socket: null,
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default: no commands intercepted
|
|
||||||
mockUseOrchestratorCommands.mockReturnValue({
|
|
||||||
isCommand: vi.fn().mockReturnValue(false),
|
|
||||||
executeCommand: vi.fn().mockResolvedValue(null),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -179,105 +151,4 @@ 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,11 +3,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { useChat } from "@/hooks/useChat";
|
import { useChat } from "@/hooks/useChat";
|
||||||
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebSocket";
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
import { ChatEmptyState } from "./ChatEmptyState";
|
|
||||||
import type { Message } from "@/hooks/useChat";
|
import type { Message } from "@/hooks/useChat";
|
||||||
|
|
||||||
export interface ChatRef {
|
export interface ChatRef {
|
||||||
@@ -61,14 +59,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const { user, isLoading: authLoading } = useAuth();
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
|
||||||
// Model and params state — initialized from ChatInput's persisted values
|
|
||||||
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
|
||||||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
|
||||||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
|
||||||
|
|
||||||
// Suggestion fill value: controls ChatInput's textarea content
|
|
||||||
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isLoading: isChatLoading,
|
isLoading: isChatLoading,
|
||||||
@@ -80,19 +70,14 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
abortStream,
|
abortStream,
|
||||||
loadConversation,
|
loadConversation,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
setMessages,
|
|
||||||
clearError,
|
clearError,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
model: selectedModel,
|
model: "llama3.2",
|
||||||
temperature,
|
|
||||||
maxTokens,
|
|
||||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||||
|
|
||||||
const { isCommand, executeCommand } = useOrchestratorCommands();
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
||||||
@@ -103,11 +88,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
const streamingMessageId =
|
const streamingMessageId =
|
||||||
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
||||||
|
|
||||||
// Whether the conversation is empty (only welcome message or no messages)
|
|
||||||
const isEmptyConversation =
|
|
||||||
messages.length === 0 ||
|
|
||||||
(messages.length === 1 && messages[0]?.id === "welcome" && !isChatLoading && !isStreaming);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
loadConversation: async (cId: string): Promise<void> => {
|
loadConversation: async (cId: string): Promise<void> => {
|
||||||
await loadConversation(cId);
|
await loadConversation(cId);
|
||||||
@@ -142,29 +122,16 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
// Cmd/Ctrl + / : Focus input
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
// Cmd/Ctrl + N : Start new conversation
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
|
|
||||||
e.preventDefault();
|
|
||||||
startNewConversation(null);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
// Cmd/Ctrl + L : Clear / start new conversation
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === "l" || e.key === "L")) {
|
|
||||||
e.preventDefault();
|
|
||||||
startNewConversation(null);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return (): void => {
|
return (): void => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [startNewConversation]);
|
}, []);
|
||||||
|
|
||||||
// Show loading quips only during non-streaming load (initial fetch wait)
|
// Show loading quips only during non-streaming load (initial fetch wait)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,37 +163,11 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string) => {
|
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);
|
await sendMessage(content);
|
||||||
},
|
},
|
||||||
[isCommand, executeCommand, setMessages, sendMessage]
|
[sendMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSuggestionClick = useCallback((prompt: string): void => {
|
|
||||||
setSuggestionValue(prompt);
|
|
||||||
// Clear after a tick so input receives it, then focus
|
|
||||||
setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -273,17 +214,13 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
||||||
{isEmptyConversation ? (
|
<MessageList
|
||||||
<ChatEmptyState onSuggestionClick={handleSuggestionClick} />
|
messages={messages as (Message & { thinking?: string })[]}
|
||||||
) : (
|
isLoading={isChatLoading}
|
||||||
<MessageList
|
isStreaming={isStreaming}
|
||||||
messages={messages as (Message & { thinking?: string })[]}
|
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
||||||
isLoading={isChatLoading}
|
loadingQuip={loadingQuip}
|
||||||
isStreaming={isStreaming}
|
/>
|
||||||
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
|
||||||
loadingQuip={loadingQuip}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,10 +288,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onStopStreaming={abortStream}
|
onStopStreaming={abortStream}
|
||||||
onModelChange={setSelectedModel}
|
|
||||||
onTemperatureChange={setTemperature}
|
|
||||||
onMaxTokensChange={setMaxTokens}
|
|
||||||
{...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file ChatEmptyState.test.tsx
|
|
||||||
* @description Tests for ChatEmptyState component: greeting, suggestions, click handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { ChatEmptyState } from "./ChatEmptyState";
|
|
||||||
|
|
||||||
describe("ChatEmptyState", () => {
|
|
||||||
it("should render the greeting heading", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
expect(screen.getByRole("heading", { name: /how can i help/i })).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the empty state container", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId("chat-empty-state")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render four suggestion buttons", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
// Four suggestions
|
|
||||||
const buttons = screen.getAllByRole("button");
|
|
||||||
expect(buttons.length).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render 'Explain this project' suggestion", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
expect(screen.getByText("Explain this project")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render 'Help me debug' suggestion", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
expect(screen.getByText("Help me debug")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render 'Write a test for' suggestion", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
expect(screen.getByText("Write a test for")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render 'Refactor this code' suggestion", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
expect(screen.getByText("Refactor this code")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onSuggestionClick with the correct prompt when a suggestion is clicked", () => {
|
|
||||||
const onSuggestionClick = vi.fn();
|
|
||||||
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
|
||||||
|
|
||||||
const explainButton = screen.getByTestId("suggestion-explain-this-project");
|
|
||||||
fireEvent.click(explainButton);
|
|
||||||
|
|
||||||
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
|
||||||
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
|
|
||||||
expect(calledWith).toContain("overview of this project");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onSuggestionClick for 'Help me debug' prompt", () => {
|
|
||||||
const onSuggestionClick = vi.fn();
|
|
||||||
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
|
||||||
|
|
||||||
const debugButton = screen.getByTestId("suggestion-help-me-debug");
|
|
||||||
fireEvent.click(debugButton);
|
|
||||||
|
|
||||||
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
|
||||||
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
|
|
||||||
expect(calledWith).toContain("debugging");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onSuggestionClick for 'Write a test for' prompt", () => {
|
|
||||||
const onSuggestionClick = vi.fn();
|
|
||||||
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
|
||||||
|
|
||||||
const testButton = screen.getByTestId("suggestion-write-a-test-for");
|
|
||||||
fireEvent.click(testButton);
|
|
||||||
|
|
||||||
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onSuggestionClick for 'Refactor this code' prompt", () => {
|
|
||||||
const onSuggestionClick = vi.fn();
|
|
||||||
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
|
||||||
|
|
||||||
const refactorButton = screen.getByTestId("suggestion-refactor-this-code");
|
|
||||||
fireEvent.click(refactorButton);
|
|
||||||
|
|
||||||
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
|
||||||
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
|
|
||||||
expect(calledWith).toContain("refactor");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have accessible aria-label on each suggestion button", () => {
|
|
||||||
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
|
||||||
const buttons = screen.getAllByRole("button");
|
|
||||||
for (const button of buttons) {
|
|
||||||
const label = button.getAttribute("aria-label");
|
|
||||||
expect(label).toBeTruthy();
|
|
||||||
expect(label).toMatch(/suggestion:/i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface Suggestion {
|
|
||||||
label: string;
|
|
||||||
prompt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUGGESTIONS: Suggestion[] = [
|
|
||||||
{
|
|
||||||
label: "Explain this project",
|
|
||||||
prompt: "Can you give me an overview of this project and its key components?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Help me debug",
|
|
||||||
prompt: "I have a bug I need help debugging. Can you walk me through the process?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Write a test for",
|
|
||||||
prompt: "Can you help me write a test for the following function or component?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Refactor this code",
|
|
||||||
prompt: "I have some code I'd like to refactor for better readability and maintainability.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ChatEmptyStateProps {
|
|
||||||
onSuggestionClick: (prompt: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatEmptyState({ onSuggestionClick }: ChatEmptyStateProps): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center justify-center gap-6 py-12 px-4 text-center"
|
|
||||||
data-testid="chat-empty-state"
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div
|
|
||||||
className="flex h-16 w-16 items-center justify-center rounded-2xl"
|
|
||||||
style={{ backgroundColor: "rgb(var(--accent-primary) / 0.12)" }}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-8 w-8"
|
|
||||||
style={{ color: "rgb(var(--accent-primary))" }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Greeting */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-lg font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
|
||||||
How can I help you today?
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm max-w-sm" style={{ color: "rgb(var(--text-secondary))" }}>
|
|
||||||
Ask me anything — I can help with code, explanations, debugging, and more.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Suggestions */}
|
|
||||||
<div className="grid grid-cols-1 gap-2 w-full max-w-sm sm:grid-cols-2">
|
|
||||||
{SUGGESTIONS.map((suggestion) => (
|
|
||||||
<button
|
|
||||||
key={suggestion.label}
|
|
||||||
onClick={() => {
|
|
||||||
onSuggestionClick(suggestion.prompt);
|
|
||||||
}}
|
|
||||||
className="rounded-lg border px-3 py-2.5 text-left text-sm transition-all duration-150 hover:shadow-sm focus:outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgb(var(--surface-1))",
|
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
color: "rgb(var(--text-secondary))",
|
|
||||||
}}
|
|
||||||
aria-label={`Suggestion: ${suggestion.label}`}
|
|
||||||
data-testid={`suggestion-${suggestion.label.toLowerCase().replace(/\s+/g, "-")}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block text-xs font-medium mb-0.5"
|
|
||||||
style={{ color: "rgb(var(--text-primary))" }}
|
|
||||||
>
|
|
||||||
{suggestion.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="block text-xs leading-relaxed line-clamp-2"
|
|
||||||
style={{ color: "rgb(var(--text-muted))" }}
|
|
||||||
>
|
|
||||||
{suggestion.prompt}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file ChatInput.test.tsx
|
|
||||||
* @description Tests for ChatInput: model selector, temperature/params, localStorage persistence,
|
|
||||||
* and command autocomplete.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
ChatInput,
|
|
||||||
AVAILABLE_MODELS,
|
|
||||||
DEFAULT_MODEL,
|
|
||||||
DEFAULT_TEMPERATURE,
|
|
||||||
DEFAULT_MAX_TOKENS,
|
|
||||||
} from "./ChatInput";
|
|
||||||
|
|
||||||
// Mock fetch for version.json
|
|
||||||
beforeEach(() => {
|
|
||||||
global.fetch = vi.fn().mockRejectedValue(new Error("Not found"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Get the first non-default model from the list */
|
|
||||||
function getNonDefaultModel(): (typeof AVAILABLE_MODELS)[number] {
|
|
||||||
const model = AVAILABLE_MODELS.find((m) => m.id !== DEFAULT_MODEL);
|
|
||||||
if (!model) throw new Error("No non-default model found");
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ChatInput — model selector", () => {
|
|
||||||
it("should render the model selector chip showing the default model", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
const defaultLabel =
|
|
||||||
AVAILABLE_MODELS.find((m) => m.id === DEFAULT_MODEL)?.label ?? DEFAULT_MODEL;
|
|
||||||
expect(screen.getByText(defaultLabel)).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should open the model dropdown when the chip is clicked", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
const chip = screen.getByLabelText(/model:/i);
|
|
||||||
fireEvent.click(chip);
|
|
||||||
|
|
||||||
// The dropdown (listbox role) should be visible
|
|
||||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
|
||||||
expect(listbox).toBeDefined();
|
|
||||||
|
|
||||||
// All model options should appear in the dropdown
|
|
||||||
const options = within(listbox).getAllByRole("option");
|
|
||||||
expect(options.length).toBe(AVAILABLE_MODELS.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onModelChange when a model is selected", async () => {
|
|
||||||
const onModelChange = vi.fn();
|
|
||||||
render(<ChatInput onSend={vi.fn()} onModelChange={onModelChange} />);
|
|
||||||
|
|
||||||
const chip = screen.getByLabelText(/model:/i);
|
|
||||||
fireEvent.click(chip);
|
|
||||||
|
|
||||||
const targetModel = getNonDefaultModel();
|
|
||||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
|
||||||
const targetOption = within(listbox).getByText(targetModel.label);
|
|
||||||
fireEvent.click(targetOption);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const calls = onModelChange.mock.calls.map((c: unknown[]) => c[0]);
|
|
||||||
expect(calls).toContain(targetModel.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should persist the selected model in localStorage", async () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
|
|
||||||
const chip = screen.getByLabelText(/model:/i);
|
|
||||||
fireEvent.click(chip);
|
|
||||||
|
|
||||||
const targetModel = getNonDefaultModel();
|
|
||||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
|
||||||
const targetOption = within(listbox).getByText(targetModel.label);
|
|
||||||
fireEvent.click(targetOption);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(localStorage.getItem("chat:selectedModel")).toBe(targetModel.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should restore the model from localStorage on mount", async () => {
|
|
||||||
const targetModel = getNonDefaultModel();
|
|
||||||
localStorage.setItem("chat:selectedModel", targetModel.id);
|
|
||||||
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(targetModel.label)).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should close the dropdown after selecting a model", async () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
|
|
||||||
const chip = screen.getByLabelText(/model:/i);
|
|
||||||
fireEvent.click(chip);
|
|
||||||
|
|
||||||
const targetModel = getNonDefaultModel();
|
|
||||||
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
|
||||||
const targetOption = within(listbox).getByText(targetModel.label);
|
|
||||||
fireEvent.click(targetOption);
|
|
||||||
|
|
||||||
// After selection, dropdown should close
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByRole("listbox")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have aria-expanded on the model chip button", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
const chip = screen.getByLabelText(/model:/i);
|
|
||||||
expect(chip.getAttribute("aria-expanded")).toBe("false");
|
|
||||||
|
|
||||||
fireEvent.click(chip);
|
|
||||||
expect(chip.getAttribute("aria-expanded")).toBe("true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ChatInput — temperature and max tokens", () => {
|
|
||||||
it("should render the settings/params button", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
const settingsBtn = screen.getByLabelText(/chat parameters/i);
|
|
||||||
expect(settingsBtn).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should open the params popover when settings button is clicked", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
const settingsBtn = screen.getByLabelText(/chat parameters/i);
|
|
||||||
fireEvent.click(settingsBtn);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/temperature/i)).toBeDefined();
|
|
||||||
expect(screen.getByLabelText(/maximum tokens/i)).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show the default temperature value", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
|
||||||
|
|
||||||
const slider = screen.getByLabelText(/temperature/i);
|
|
||||||
expect(parseFloat((slider as HTMLInputElement).value)).toBeCloseTo(DEFAULT_TEMPERATURE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onTemperatureChange when the slider is moved", async () => {
|
|
||||||
const onTemperatureChange = vi.fn();
|
|
||||||
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
|
||||||
|
|
||||||
const slider = screen.getByLabelText(/temperature/i);
|
|
||||||
fireEvent.change(slider, { target: { value: "1.2" } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
|
|
||||||
expect(calls).toContain(1.2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should persist temperature in localStorage", async () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
|
||||||
|
|
||||||
const slider = screen.getByLabelText(/temperature/i);
|
|
||||||
fireEvent.change(slider, { target: { value: "0.5" } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(localStorage.getItem("chat:temperature")).toBe("0.5");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should restore temperature from localStorage on mount", async () => {
|
|
||||||
localStorage.setItem("chat:temperature", "1.5");
|
|
||||||
|
|
||||||
const onTemperatureChange = vi.fn();
|
|
||||||
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
|
|
||||||
expect(calls).toContain(1.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show the default max tokens value", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText(/maximum tokens/i);
|
|
||||||
expect(parseInt((input as HTMLInputElement).value, 10)).toBe(DEFAULT_MAX_TOKENS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onMaxTokensChange when the max tokens input changes", async () => {
|
|
||||||
const onMaxTokensChange = vi.fn();
|
|
||||||
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText(/maximum tokens/i);
|
|
||||||
fireEvent.change(input, { target: { value: "8192" } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
|
|
||||||
expect(calls).toContain(8192);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should persist max tokens in localStorage", async () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText(/maximum tokens/i);
|
|
||||||
fireEvent.change(input, { target: { value: "2000" } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(localStorage.getItem("chat:maxTokens")).toBe("2000");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should restore max tokens from localStorage on mount", async () => {
|
|
||||||
localStorage.setItem("chat:maxTokens", "8000");
|
|
||||||
|
|
||||||
const onMaxTokensChange = vi.fn();
|
|
||||||
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
|
|
||||||
expect(calls).toContain(8000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ChatInput — externalValue (suggestion fill)", () => {
|
|
||||||
it("should update the textarea when externalValue is provided", async () => {
|
|
||||||
const { rerender } = render(<ChatInput onSend={vi.fn()} />);
|
|
||||||
|
|
||||||
const textarea = screen.getByLabelText(/message input/i);
|
|
||||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
|
||||||
|
|
||||||
rerender(<ChatInput onSend={vi.fn()} externalValue="Hello suggestion" />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect((textarea as HTMLTextAreaElement).value).toBe("Hello suggestion");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ChatInput — send behavior", () => {
|
|
||||||
it("should call onSend with the message when the send button is clicked", async () => {
|
|
||||||
const onSend = vi.fn();
|
|
||||||
render(<ChatInput onSend={onSend} />);
|
|
||||||
|
|
||||||
const textarea = screen.getByLabelText(/message input/i);
|
|
||||||
fireEvent.change(textarea, { target: { value: "Hello world" } });
|
|
||||||
|
|
||||||
const sendButton = screen.getByLabelText(/send message/i);
|
|
||||||
fireEvent.click(sendButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(onSend).toHaveBeenCalledWith("Hello world");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clear the textarea after sending", async () => {
|
|
||||||
const onSend = vi.fn();
|
|
||||||
render(<ChatInput onSend={onSend} />);
|
|
||||||
|
|
||||||
const textarea = screen.getByLabelText(/message input/i);
|
|
||||||
fireEvent.change(textarea, { target: { value: "Hello world" } });
|
|
||||||
fireEvent.click(screen.getByLabelText(/send message/i));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show the stop button when streaming", () => {
|
|
||||||
render(<ChatInput onSend={vi.fn()} isStreaming={true} />);
|
|
||||||
expect(screen.getByLabelText(/stop generating/i)).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onStopStreaming when stop button is clicked", () => {
|
|
||||||
const onStop = vi.fn();
|
|
||||||
render(<ChatInput onSend={vi.fn()} isStreaming={true} onStopStreaming={onStop} />);
|
|
||||||
fireEvent.click(screen.getByLabelText(/stop generating/i));
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KeyboardEvent, RefObject } from "react";
|
import type { KeyboardEvent, RefObject } from "react";
|
||||||
import { useCallback, useState, useEffect, useRef } from "react";
|
import { useCallback, useState, useEffect } from "react";
|
||||||
import { ORCHESTRATOR_COMMANDS } from "@/hooks/useOrchestratorCommands";
|
|
||||||
|
|
||||||
export const AVAILABLE_MODELS = [
|
|
||||||
{ id: "llama3.2", label: "Llama 3.2" },
|
|
||||||
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
|
||||||
{ id: "gpt-4o", label: "GPT-4o" },
|
|
||||||
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"];
|
|
||||||
|
|
||||||
const STORAGE_KEY_MODEL = "chat:selectedModel";
|
|
||||||
const STORAGE_KEY_TEMPERATURE = "chat:temperature";
|
|
||||||
const STORAGE_KEY_MAX_TOKENS = "chat:maxTokens";
|
|
||||||
|
|
||||||
export const DEFAULT_TEMPERATURE = 0.7;
|
|
||||||
export const DEFAULT_MAX_TOKENS = 4096;
|
|
||||||
export const DEFAULT_MODEL: ModelId = "llama3.2";
|
|
||||||
|
|
||||||
function loadStoredModel(): ModelId {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY_MODEL);
|
|
||||||
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
|
|
||||||
return stored as ModelId;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
return DEFAULT_MODEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStoredTemperature(): number {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY_TEMPERATURE);
|
|
||||||
if (stored !== null) {
|
|
||||||
const parsed = parseFloat(stored);
|
|
||||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
return DEFAULT_TEMPERATURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStoredMaxTokens(): number {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY_MAX_TOKENS);
|
|
||||||
if (stored !== null) {
|
|
||||||
const parsed = parseInt(stored, 10);
|
|
||||||
if (!isNaN(parsed) && parsed >= 100 && parsed <= 32000) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
return DEFAULT_MAX_TOKENS;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
@@ -69,11 +9,6 @@ interface ChatInputProps {
|
|||||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
onStopStreaming?: () => void;
|
onStopStreaming?: () => void;
|
||||||
onModelChange?: (model: ModelId) => void;
|
|
||||||
onTemperatureChange?: (temperature: number) => void;
|
|
||||||
onMaxTokensChange?: (maxTokens: number) => void;
|
|
||||||
onSuggestionFill?: (text: string) => void;
|
|
||||||
externalValue?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -82,57 +17,9 @@ export function ChatInput({
|
|||||||
inputRef,
|
inputRef,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
onStopStreaming,
|
onStopStreaming,
|
||||||
onModelChange,
|
|
||||||
onTemperatureChange,
|
|
||||||
onMaxTokensChange,
|
|
||||||
externalValue,
|
|
||||||
}: ChatInputProps): React.JSX.Element {
|
}: ChatInputProps): React.JSX.Element {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
const [selectedModel, setSelectedModel] = useState<ModelId>(DEFAULT_MODEL);
|
|
||||||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
|
||||||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Stable refs for callbacks so the mount effect stays dependency-free
|
|
||||||
const onModelChangeRef = useRef(onModelChange);
|
|
||||||
onModelChangeRef.current = onModelChange;
|
|
||||||
const onTemperatureChangeRef = useRef(onTemperatureChange);
|
|
||||||
onTemperatureChangeRef.current = onTemperatureChange;
|
|
||||||
const onMaxTokensChangeRef = useRef(onMaxTokensChange);
|
|
||||||
onMaxTokensChangeRef.current = onMaxTokensChange;
|
|
||||||
|
|
||||||
// Load persisted values from localStorage on mount only
|
|
||||||
useEffect(() => {
|
|
||||||
const storedModel = loadStoredModel();
|
|
||||||
const storedTemperature = loadStoredTemperature();
|
|
||||||
const storedMaxTokens = loadStoredMaxTokens();
|
|
||||||
|
|
||||||
setSelectedModel(storedModel);
|
|
||||||
setTemperature(storedTemperature);
|
|
||||||
setMaxTokens(storedMaxTokens);
|
|
||||||
|
|
||||||
// Notify parent of initial values via refs to avoid stale closure
|
|
||||||
onModelChangeRef.current?.(storedModel);
|
|
||||||
onTemperatureChangeRef.current?.(storedTemperature);
|
|
||||||
onMaxTokensChangeRef.current?.(storedMaxTokens);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Sync external value (e.g. from suggestion clicks)
|
|
||||||
useEffect(() => {
|
|
||||||
if (externalValue !== undefined) {
|
|
||||||
setMessage(externalValue);
|
|
||||||
}
|
|
||||||
}, [externalValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
interface VersionData {
|
interface VersionData {
|
||||||
@@ -153,54 +40,6 @@ 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 => {
|
|
||||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
|
|
||||||
setIsModelDropdownOpen(false);
|
|
||||||
}
|
|
||||||
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 => {
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (message.trim() && !disabled && !isStreaming) {
|
if (message.trim() && !disabled && !isStreaming) {
|
||||||
onSend(message);
|
onSend(message);
|
||||||
@@ -212,48 +51,8 @@ export function ChatInput({
|
|||||||
onStopStreaming?.();
|
onStopStreaming?.();
|
||||||
}, [onStopStreaming]);
|
}, [onStopStreaming]);
|
||||||
|
|
||||||
const acceptCommand = useCallback((cmdName: string): void => {
|
|
||||||
setMessage(cmdName + " ");
|
|
||||||
setCommandSuggestions([]);
|
|
||||||
setHighlightedCommandIndex(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
(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) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
@@ -263,52 +62,9 @@ export function ChatInput({
|
|||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSubmit, commandSuggestions, highlightedCommandIndex, acceptCommand]
|
[handleSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleModelSelect = useCallback(
|
|
||||||
(model: ModelId): void => {
|
|
||||||
setSelectedModel(model);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY_MODEL, model);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
onModelChange?.(model);
|
|
||||||
setIsModelDropdownOpen(false);
|
|
||||||
},
|
|
||||||
[onModelChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTemperatureChange = useCallback(
|
|
||||||
(value: number): void => {
|
|
||||||
setTemperature(value);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY_TEMPERATURE, value.toString());
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
onTemperatureChange?.(value);
|
|
||||||
},
|
|
||||||
[onTemperatureChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMaxTokensChange = useCallback(
|
|
||||||
(value: number): void => {
|
|
||||||
setMaxTokens(value);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY_MAX_TOKENS, value.toString());
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
onMaxTokensChange?.(value);
|
|
||||||
},
|
|
||||||
[onMaxTokensChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedModelLabel =
|
|
||||||
AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel;
|
|
||||||
|
|
||||||
const characterCount = message.length;
|
const characterCount = message.length;
|
||||||
const maxCharacters = 4000;
|
const maxCharacters = 4000;
|
||||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||||
@@ -316,279 +72,7 @@ export function ChatInput({
|
|||||||
const isInputDisabled = disabled ?? false;
|
const isInputDisabled = disabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{/* Model Selector + Params Row */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Model Selector */}
|
|
||||||
<div className="relative" ref={modelDropdownRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIsModelDropdownOpen((prev) => !prev);
|
|
||||||
setIsParamsOpen(false);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
backgroundColor: "rgb(var(--surface-1))",
|
|
||||||
color: "rgb(var(--text-secondary))",
|
|
||||||
}}
|
|
||||||
aria-label={`Model: ${selectedModelLabel}. Click to change`}
|
|
||||||
aria-expanded={isModelDropdownOpen}
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
title="Select AI model"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-3 w-3 flex-shrink-0"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
|
|
||||||
</svg>
|
|
||||||
<span>{selectedModelLabel}</span>
|
|
||||||
<svg
|
|
||||||
className={`h-3 w-3 transition-transform ${isModelDropdownOpen ? "rotate-180" : ""}`}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Model Dropdown */}
|
|
||||||
{isModelDropdownOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-full left-0 mb-1 z-50 min-w-[160px] rounded-lg border shadow-lg"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
}}
|
|
||||||
role="listbox"
|
|
||||||
aria-label="Available models"
|
|
||||||
>
|
|
||||||
{AVAILABLE_MODELS.map((model) => (
|
|
||||||
<button
|
|
||||||
key={model.id}
|
|
||||||
role="option"
|
|
||||||
aria-selected={model.id === selectedModel}
|
|
||||||
onClick={() => {
|
|
||||||
handleModelSelect(model.id);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-left text-xs transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-black/5"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
model.id === selectedModel
|
|
||||||
? "rgb(var(--accent-primary))"
|
|
||||||
: "rgb(var(--text-primary))",
|
|
||||||
fontWeight: model.id === selectedModel ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{model.label}
|
|
||||||
{model.id === selectedModel && (
|
|
||||||
<svg
|
|
||||||
className="inline-block ml-1.5 h-3 w-3"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={3}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings / Params Icon */}
|
|
||||||
<div className="relative" ref={paramsDropdownRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIsParamsOpen((prev) => !prev);
|
|
||||||
setIsModelDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
className="flex items-center justify-center rounded-full border p-1 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
backgroundColor: isParamsOpen ? "rgb(var(--surface-2))" : "rgb(var(--surface-1))",
|
|
||||||
color: "rgb(var(--text-muted))",
|
|
||||||
}}
|
|
||||||
aria-label="Chat parameters"
|
|
||||||
aria-expanded={isParamsOpen}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
title="Configure temperature and max tokens"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Params Popover */}
|
|
||||||
{isParamsOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-full left-0 mb-1 z-50 w-64 rounded-lg border p-4 shadow-lg"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Chat parameters"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
className="mb-3 text-xs font-semibold uppercase tracking-wide"
|
|
||||||
style={{ color: "rgb(var(--text-muted))" }}
|
|
||||||
>
|
|
||||||
Parameters
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Temperature */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
|
||||||
<label
|
|
||||||
className="text-xs font-medium"
|
|
||||||
style={{ color: "rgb(var(--text-secondary))" }}
|
|
||||||
htmlFor="temperature-slider"
|
|
||||||
>
|
|
||||||
Temperature
|
|
||||||
</label>
|
|
||||||
<span
|
|
||||||
className="text-xs font-mono tabular-nums"
|
|
||||||
style={{ color: "rgb(var(--accent-primary))" }}
|
|
||||||
>
|
|
||||||
{temperature.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="temperature-slider"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={2}
|
|
||||||
step={0.1}
|
|
||||||
value={temperature}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleTemperatureChange(parseFloat(e.target.value));
|
|
||||||
}}
|
|
||||||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
|
|
||||||
style={{
|
|
||||||
accentColor: "rgb(var(--accent-primary))",
|
|
||||||
backgroundColor: "rgb(var(--surface-2))",
|
|
||||||
}}
|
|
||||||
aria-label={`Temperature: ${temperature.toFixed(1)}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="mt-1 flex justify-between text-[10px]"
|
|
||||||
style={{ color: "rgb(var(--text-muted))" }}
|
|
||||||
>
|
|
||||||
<span>Precise</span>
|
|
||||||
<span>Creative</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Max Tokens */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="mb-1.5 block text-xs font-medium"
|
|
||||||
style={{ color: "rgb(var(--text-secondary))" }}
|
|
||||||
htmlFor="max-tokens-input"
|
|
||||||
>
|
|
||||||
Max Tokens
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="max-tokens-input"
|
|
||||||
type="number"
|
|
||||||
min={100}
|
|
||||||
max={32000}
|
|
||||||
step={100}
|
|
||||||
value={maxTokens}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = parseInt(e.target.value, 10);
|
|
||||||
if (!isNaN(val) && val >= 100 && val <= 32000) {
|
|
||||||
handleMaxTokensChange(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full rounded-md border px-2.5 py-1.5 text-xs outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgb(var(--surface-1))",
|
|
||||||
borderColor: "rgb(var(--border-default))",
|
|
||||||
color: "rgb(var(--text-primary))",
|
|
||||||
}}
|
|
||||||
aria-label="Maximum tokens"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px]" style={{ color: "rgb(var(--text-muted))" }}>
|
|
||||||
100 – 32,000
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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 */}
|
{/* Input Container */}
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg border transition-all duration-150"
|
className="relative rounded-lg border transition-all duration-150"
|
||||||
|
|||||||
@@ -250,46 +250,6 @@ describe("ChatOverlay", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("new conversation button", () => {
|
|
||||||
it("should render the new conversation button when chat is open", async () => {
|
|
||||||
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
|
||||||
vi.mocked(useChatOverlay).mockReturnValue({
|
|
||||||
isOpen: true,
|
|
||||||
isMinimized: false,
|
|
||||||
open: mockOpen,
|
|
||||||
close: mockClose,
|
|
||||||
minimize: mockMinimize,
|
|
||||||
expand: mockExpand,
|
|
||||||
toggle: mockToggle,
|
|
||||||
toggleMinimize: mockToggleMinimize,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ChatOverlay />);
|
|
||||||
|
|
||||||
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
|
|
||||||
expect(newConvBtn).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have a tooltip on the new conversation button", async () => {
|
|
||||||
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
|
||||||
vi.mocked(useChatOverlay).mockReturnValue({
|
|
||||||
isOpen: true,
|
|
||||||
isMinimized: false,
|
|
||||||
open: mockOpen,
|
|
||||||
close: mockClose,
|
|
||||||
minimize: mockMinimize,
|
|
||||||
expand: mockExpand,
|
|
||||||
toggle: mockToggle,
|
|
||||||
toggleMinimize: mockToggleMinimize,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ChatOverlay />);
|
|
||||||
|
|
||||||
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
|
|
||||||
expect(newConvBtn.getAttribute("title")).toContain("Cmd+N");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("responsive design", () => {
|
describe("responsive design", () => {
|
||||||
it("should render as a sidebar on desktop", () => {
|
it("should render as a sidebar on desktop", () => {
|
||||||
render(<ChatOverlay />);
|
render(<ChatOverlay />);
|
||||||
|
|||||||
@@ -164,27 +164,6 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Header Controls */}
|
{/* Header Controls */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* New Conversation Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
chatRef.current?.startNewConversation(null);
|
|
||||||
}}
|
|
||||||
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
|
||||||
aria-label="New conversation"
|
|
||||||
title="New conversation (Cmd+N)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4"
|
|
||||||
style={{ color: "rgb(var(--text-secondary))" }}
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Minimize Button */}
|
{/* Minimize Button */}
|
||||||
<button
|
<button
|
||||||
onClick={minimize}
|
onClick={minimize}
|
||||||
|
|||||||
@@ -11,17 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { Chat, type ChatRef, type NewConversationData } from "./Chat";
|
export { Chat, type ChatRef, type NewConversationData } from "./Chat";
|
||||||
export {
|
export { ChatInput } from "./ChatInput";
|
||||||
ChatInput,
|
|
||||||
AVAILABLE_MODELS,
|
|
||||||
DEFAULT_MODEL,
|
|
||||||
DEFAULT_TEMPERATURE,
|
|
||||||
DEFAULT_MAX_TOKENS,
|
|
||||||
} from "./ChatInput";
|
|
||||||
export type { ModelId } from "./ChatInput";
|
|
||||||
export { MessageList } from "./MessageList";
|
export { MessageList } from "./MessageList";
|
||||||
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
||||||
export { BackendStatusBanner } from "./BackendStatusBanner";
|
export { BackendStatusBanner } from "./BackendStatusBanner";
|
||||||
export { ChatOverlay } from "./ChatOverlay";
|
export { ChatOverlay } from "./ChatOverlay";
|
||||||
export { ChatEmptyState } from "./ChatEmptyState";
|
|
||||||
export type { Message } from "@/hooks/useChat";
|
export type { Message } from "@/hooks/useChat";
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file AgentTerminal.test.tsx
|
|
||||||
* @description Unit tests for the AgentTerminal component
|
|
||||||
*
|
|
||||||
* Tests cover:
|
|
||||||
* - Output rendering
|
|
||||||
* - Status display (status indicator + badge)
|
|
||||||
* - ANSI stripping
|
|
||||||
* - Agent header information (type, duration, jobId)
|
|
||||||
* - Auto-scroll behavior
|
|
||||||
* - Copy-to-clipboard
|
|
||||||
* - Error message display
|
|
||||||
* - Empty state rendering
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import { AgentTerminal } from "./AgentTerminal";
|
|
||||||
import type { AgentSession } from "@/hooks/useAgentStream";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mock navigator.clipboard
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const mockWriteText = vi.fn(() => Promise.resolve());
|
|
||||||
Object.defineProperty(navigator, "clipboard", {
|
|
||||||
value: { writeText: mockWriteText },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Factory helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
||||||
return {
|
|
||||||
agentId: "test-agent-1",
|
|
||||||
agentType: "worker",
|
|
||||||
status: "running",
|
|
||||||
outputLines: [],
|
|
||||||
startedAt: Date.now() - 5000, // 5s ago
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tests
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("AgentTerminal", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Rendering
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("rendering", () => {
|
|
||||||
it("renders the agent terminal container", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-terminal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has region role with agent type label", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ agentType: "planner" })} />) as ReactElement);
|
|
||||||
expect(screen.getByRole("region", { name: "Agent output: planner" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets data-agent-id attribute", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ agentId: "my-agent-123" })} />) as ReactElement);
|
|
||||||
const container = screen.getByTestId("agent-terminal");
|
|
||||||
expect(container).toHaveAttribute("data-agent-id", "my-agent-123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies className prop to the outer container", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} className="custom-cls" />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-terminal")).toHaveClass("custom-cls");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Header
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("header", () => {
|
|
||||||
it("renders the agent type label", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ agentType: "coordinator" })} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("coordinator");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes jobId in the label when provided", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={makeAgent({ agentType: "worker", jobId: "job-42" })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("worker · job-42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show jobId separator when jobId is absent", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ agentType: "worker" })} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-type-label")).not.toHaveTextContent("·");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the duration element", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-duration")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows seconds for short-running agents", () => {
|
|
||||||
const agent = makeAgent({ startedAt: Date.now() - 8000 });
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-duration")).toHaveTextContent("s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows minutes for long-running agents", () => {
|
|
||||||
const agent = makeAgent({ startedAt: Date.now() - 125000 }); // 2m 5s
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-duration")).toHaveTextContent("m");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Status indicator
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("status indicator", () => {
|
|
||||||
it("shows a running indicator for running status", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
|
|
||||||
const indicator = screen.getByTestId("status-indicator");
|
|
||||||
expect(indicator).toHaveAttribute("data-status", "running");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a spawning indicator for spawning status", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
|
|
||||||
const indicator = screen.getByTestId("status-indicator");
|
|
||||||
expect(indicator).toHaveAttribute("data-status", "spawning");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows completed indicator for completed status", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
const indicator = screen.getByTestId("status-indicator");
|
|
||||||
expect(indicator).toHaveAttribute("data-status", "completed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error indicator for error status", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
const indicator = screen.getByTestId("status-indicator");
|
|
||||||
expect(indicator).toHaveAttribute("data-status", "error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Status badge
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("status badge", () => {
|
|
||||||
it("renders the status badge", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("status-badge")).toHaveTextContent("running");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'spawning' badge for spawning status", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("status-badge")).toHaveTextContent("spawning");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'completed' badge for completed status", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("status-badge")).toHaveTextContent("completed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'error' badge for error status", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("status-badge")).toHaveTextContent("error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Output rendering
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("output area", () => {
|
|
||||||
it("renders the output pre element", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-output")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'Waiting for output...' when outputLines is empty and status is running", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={makeAgent({ status: "running", outputLines: [] })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("agent-output")).toHaveTextContent("Waiting for output...");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'Spawning agent...' when status is spawning and no output", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={makeAgent({ status: "spawning", outputLines: [] })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("agent-output")).toHaveTextContent("Spawning agent...");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders output lines as text content", () => {
|
|
||||||
const agent = makeAgent({
|
|
||||||
outputLines: ["Hello world\n", "Second line\n"],
|
|
||||||
});
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
const output = screen.getByTestId("agent-output");
|
|
||||||
expect(output).toHaveTextContent("Hello world");
|
|
||||||
expect(output).toHaveTextContent("Second line");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips ANSI escape codes from output", () => {
|
|
||||||
const agent = makeAgent({
|
|
||||||
outputLines: ["\x1b[32mGreen text\x1b[0m\n"],
|
|
||||||
});
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
const output = screen.getByTestId("agent-output");
|
|
||||||
expect(output).toHaveTextContent("Green text");
|
|
||||||
expect(output.textContent).not.toContain("\x1b");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has aria-live=polite for screen reader announcements", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-output")).toHaveAttribute("aria-live", "polite");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Error message
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("error message", () => {
|
|
||||||
it("shows error message when status is error and errorMessage is set", () => {
|
|
||||||
const agent = makeAgent({
|
|
||||||
status: "error",
|
|
||||||
endedAt: Date.now(),
|
|
||||||
errorMessage: "Process crashed",
|
|
||||||
});
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("agent-error-message")).toHaveTextContent("Process crashed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders alert role for error message", () => {
|
|
||||||
const agent = makeAgent({
|
|
||||||
status: "error",
|
|
||||||
endedAt: Date.now(),
|
|
||||||
errorMessage: "OOM killed",
|
|
||||||
});
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show error message when status is running", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
|
|
||||||
expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show error message when status is error but errorMessage is absent", () => {
|
|
||||||
const agent = makeAgent({ status: "error", endedAt: Date.now() });
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Copy to clipboard
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("copy to clipboard", () => {
|
|
||||||
it("renders the copy button", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copy button has aria-label='Copy agent output'", () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
expect(screen.getByRole("button", { name: "Copy agent output" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls clipboard.writeText with stripped output on click", async () => {
|
|
||||||
const agent = makeAgent({
|
|
||||||
outputLines: ["\x1b[32mLine 1\x1b[0m\n", "Line 2\n"],
|
|
||||||
});
|
|
||||||
render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByTestId("copy-button"));
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockWriteText).toHaveBeenCalledWith("Line 1\nLine 2\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'copied' text briefly after clicking copy", async () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByTestId("copy-button"));
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId("copy-button")).toHaveTextContent("copied");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reverts copy button text after timeout", async () => {
|
|
||||||
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByTestId("copy-button"));
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(2500);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId("copy-button")).toHaveTextContent("copy");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Auto-scroll
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("auto-scroll", () => {
|
|
||||||
it("does not throw when outputLines changes", () => {
|
|
||||||
const agent = makeAgent({ outputLines: ["Line 1\n"] });
|
|
||||||
const { rerender } = render((<AgentTerminal agent={agent} />) as ReactElement);
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
rerender(
|
|
||||||
(
|
|
||||||
<AgentTerminal agent={{ ...agent, outputLines: ["Line 1\n", "Line 2\n"] }} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AgentTerminal component
|
|
||||||
*
|
|
||||||
* Read-only terminal view for displaying orchestrator agent output.
|
|
||||||
* Uses a <pre> element with monospace font rather than xterm.js because
|
|
||||||
* this is read-only agent stdout/stderr, not an interactive PTY.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Displays accumulated output lines with basic ANSI color rendering
|
|
||||||
* - Status badge (spinning/checkmark/X) indicating agent lifecycle
|
|
||||||
* - Header bar with agent type, status, and elapsed duration
|
|
||||||
* - Auto-scrolls to bottom as new output arrives
|
|
||||||
* - Copy-to-clipboard button for full output
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
|
||||||
import type { ReactElement, CSSProperties } from "react";
|
|
||||||
import type { AgentSession, AgentStatus } from "@/hooks/useAgentStream";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Types
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export interface AgentTerminalProps {
|
|
||||||
/** The agent session to display */
|
|
||||||
agent: AgentSession;
|
|
||||||
/** Optional CSS class name for the outer container */
|
|
||||||
className?: string;
|
|
||||||
/** Optional inline style for the outer container */
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// ANSI color strip helper
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Simple ANSI escape sequence stripper — produces readable plain text for <pre>.
|
|
||||||
// We strip rather than parse for security and simplicity in read-only display.
|
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
const ANSI_PATTERN = /\x1b\[[0-9;]*[mGKHF]/g;
|
|
||||||
|
|
||||||
function stripAnsi(text: string): string {
|
|
||||||
return text.replace(ANSI_PATTERN, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Duration helper
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function formatDuration(startedAt: number, endedAt?: number): string {
|
|
||||||
const elapsed = Math.floor(((endedAt ?? Date.now()) - startedAt) / 1000);
|
|
||||||
if (elapsed < 60) return `${elapsed.toString()}s`;
|
|
||||||
const minutes = Math.floor(elapsed / 60);
|
|
||||||
const seconds = elapsed % 60;
|
|
||||||
return `${minutes.toString()}m ${seconds.toString()}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Status indicator
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface StatusIndicatorProps {
|
|
||||||
status: AgentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusIndicator({ status }: StatusIndicatorProps): ReactElement {
|
|
||||||
const baseStyle: CSSProperties = {
|
|
||||||
display: "inline-block",
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === "running" || status === "spawning") {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-testid="status-indicator"
|
|
||||||
data-status={status}
|
|
||||||
style={{
|
|
||||||
...baseStyle,
|
|
||||||
background: "var(--success)",
|
|
||||||
animation: "agentPulse 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
aria-label="Running"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "completed") {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-testid="status-indicator"
|
|
||||||
data-status={status}
|
|
||||||
style={{
|
|
||||||
...baseStyle,
|
|
||||||
background: "var(--muted)",
|
|
||||||
}}
|
|
||||||
aria-label="Completed"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// error
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-testid="status-indicator"
|
|
||||||
data-status={status}
|
|
||||||
style={{
|
|
||||||
...baseStyle,
|
|
||||||
background: "var(--danger)",
|
|
||||||
}}
|
|
||||||
aria-label="Error"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Status badge
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
status: AgentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: StatusBadgeProps): ReactElement {
|
|
||||||
const colorMap: Record<AgentStatus, string> = {
|
|
||||||
spawning: "var(--warn)",
|
|
||||||
running: "var(--success)",
|
|
||||||
completed: "var(--muted)",
|
|
||||||
error: "var(--danger)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelMap: Record<AgentStatus, string> = {
|
|
||||||
spawning: "spawning",
|
|
||||||
running: "running",
|
|
||||||
completed: "completed",
|
|
||||||
error: "error",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-testid="status-badge"
|
|
||||||
style={{
|
|
||||||
fontSize: "0.65rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: colorMap[status],
|
|
||||||
border: `1px solid ${colorMap[status]}`,
|
|
||||||
borderRadius: 3,
|
|
||||||
padding: "1px 5px",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
letterSpacing: "0.03em",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labelMap[status]}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Component
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AgentTerminal renders accumulated agent output in a scrollable pre block.
|
|
||||||
* It is intentionally read-only — no keyboard input is accepted.
|
|
||||||
*/
|
|
||||||
export function AgentTerminal({ agent, className = "", style }: AgentTerminalProps): ReactElement {
|
|
||||||
const outputRef = useRef<HTMLPreElement>(null);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [tick, setTick] = useState(0);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Duration ticker — only runs while active
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (agent.status === "running" || agent.status === "spawning") {
|
|
||||||
const id = setInterval(() => {
|
|
||||||
setTick((t) => t + 1);
|
|
||||||
}, 1000);
|
|
||||||
return (): void => {
|
|
||||||
clearInterval(id);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [agent.status]);
|
|
||||||
|
|
||||||
// Consume tick to avoid unused-var lint
|
|
||||||
void tick;
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Auto-scroll to bottom on new output
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = outputRef.current;
|
|
||||||
if (el) {
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [agent.outputLines]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Copy to clipboard
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const handleCopy = useCallback((): void => {
|
|
||||||
const text = agent.outputLines.map(stripAnsi).join("");
|
|
||||||
void navigator.clipboard.writeText(text).then(() => {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
}, [agent.outputLines]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Styles
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const containerStyle: CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
background: "var(--bg-deep)",
|
|
||||||
overflow: "hidden",
|
|
||||||
...style,
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerStyle: CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
flexShrink: 0,
|
|
||||||
background: "var(--bg-deep)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const titleStyle: CSSProperties = {
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text)",
|
|
||||||
flex: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
};
|
|
||||||
|
|
||||||
const durationStyle: CSSProperties = {
|
|
||||||
fontSize: "0.65rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--muted)",
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const outputStyle: CSSProperties = {
|
|
||||||
flex: 1,
|
|
||||||
overflow: "auto",
|
|
||||||
margin: 0,
|
|
||||||
padding: "8px 12px",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: "var(--text)",
|
|
||||||
background: "var(--bg-deep)",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyButtonStyle: CSSProperties = {
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: 3,
|
|
||||||
color: copied ? "var(--success)" : "var(--muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.65rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
padding: "2px 6px",
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const duration = formatDuration(agent.startedAt, agent.endedAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
style={containerStyle}
|
|
||||||
role="region"
|
|
||||||
aria-label={`Agent output: ${agent.agentType}`}
|
|
||||||
data-testid="agent-terminal"
|
|
||||||
data-agent-id={agent.agentId}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={headerStyle} data-testid="agent-terminal-header">
|
|
||||||
<StatusIndicator status={agent.status} />
|
|
||||||
|
|
||||||
<span style={titleStyle} data-testid="agent-type-label">
|
|
||||||
{agent.agentType}
|
|
||||||
{agent.jobId !== undefined ? ` · ${agent.jobId}` : ""}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<StatusBadge status={agent.status} />
|
|
||||||
|
|
||||||
<span style={durationStyle} data-testid="agent-duration">
|
|
||||||
{duration}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Copy button */}
|
|
||||||
<button
|
|
||||||
aria-label="Copy agent output"
|
|
||||||
style={copyButtonStyle}
|
|
||||||
onClick={handleCopy}
|
|
||||||
data-testid="copy-button"
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
if (!copied) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--text-2)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
if (!copied) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? "copied" : "copy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output area */}
|
|
||||||
<pre
|
|
||||||
ref={outputRef}
|
|
||||||
style={outputStyle}
|
|
||||||
data-testid="agent-output"
|
|
||||||
aria-label="Agent output log"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="false"
|
|
||||||
>
|
|
||||||
{agent.outputLines.length === 0 ? (
|
|
||||||
<span style={{ color: "var(--muted)" }}>
|
|
||||||
{agent.status === "spawning" ? "Spawning agent..." : "Waiting for output..."}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
agent.outputLines.map(stripAnsi).join("")
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
{/* Error message overlay */}
|
|
||||||
{agent.status === "error" && agent.errorMessage !== undefined && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "4px 12px",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
background: "var(--bg-deep)",
|
|
||||||
borderTop: "1px solid var(--border)",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
data-testid="agent-error-message"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
Error: {agent.errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pulse animation keyframes — injected inline via style tag for zero deps */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes agentPulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file TerminalPanel.test.tsx
|
|
||||||
* @description Unit tests for the TerminalPanel component — multi-tab scenarios
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mocks
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
|
|
||||||
vi.mock("./XTerminal", () => ({
|
|
||||||
XTerminal: vi.fn(
|
|
||||||
({
|
|
||||||
sessionId,
|
|
||||||
isVisible,
|
|
||||||
sessionStatus,
|
|
||||||
}: {
|
|
||||||
sessionId: string;
|
|
||||||
isVisible: boolean;
|
|
||||||
sessionStatus: string;
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
data-testid="mock-xterminal"
|
|
||||||
data-session-id={sessionId}
|
|
||||||
data-visible={isVisible ? "true" : "false"}
|
|
||||||
data-status={sessionStatus}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock AgentTerminal to avoid complexity in panel tests
|
|
||||||
vi.mock("./AgentTerminal", () => ({
|
|
||||||
AgentTerminal: vi.fn(
|
|
||||||
({ agent }: { agent: { agentId: string; agentType: string; status: string } }) => (
|
|
||||||
<div
|
|
||||||
data-testid="mock-agent-terminal"
|
|
||||||
data-agent-id={agent.agentId}
|
|
||||||
data-agent-type={agent.agentType}
|
|
||||||
data-status={agent.status}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock useTerminalSessions
|
|
||||||
const mockCreateSession = vi.fn();
|
|
||||||
const mockCloseSession = vi.fn();
|
|
||||||
const mockRenameSession = vi.fn();
|
|
||||||
const mockSetActiveSession = vi.fn();
|
|
||||||
const mockSendInput = vi.fn();
|
|
||||||
const mockResize = vi.fn();
|
|
||||||
const mockRegisterOutputCallback = vi.fn(() => vi.fn());
|
|
||||||
|
|
||||||
// Mutable state for the mock — tests update these
|
|
||||||
let mockSessions = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
sessionId: string;
|
|
||||||
name: string;
|
|
||||||
status: "active" | "exited";
|
|
||||||
exitCode?: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
let mockActiveSessionId: string | null = null;
|
|
||||||
let mockIsConnected = false;
|
|
||||||
let mockConnectionError: string | null = null;
|
|
||||||
|
|
||||||
vi.mock("@/hooks/useTerminalSessions", () => ({
|
|
||||||
useTerminalSessions: vi.fn(() => ({
|
|
||||||
sessions: mockSessions,
|
|
||||||
activeSessionId: mockActiveSessionId,
|
|
||||||
isConnected: mockIsConnected,
|
|
||||||
connectionError: mockConnectionError,
|
|
||||||
createSession: mockCreateSession,
|
|
||||||
closeSession: mockCloseSession,
|
|
||||||
renameSession: mockRenameSession,
|
|
||||||
setActiveSession: mockSetActiveSession,
|
|
||||||
sendInput: mockSendInput,
|
|
||||||
resize: mockResize,
|
|
||||||
registerOutputCallback: mockRegisterOutputCallback,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock useAgentStream
|
|
||||||
const mockDismissAgent = vi.fn();
|
|
||||||
let mockAgents = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
agentId: string;
|
|
||||||
agentType: string;
|
|
||||||
status: "spawning" | "running" | "completed" | "error";
|
|
||||||
outputLines: string[];
|
|
||||||
startedAt: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
let mockAgentStreamConnected = false;
|
|
||||||
|
|
||||||
vi.mock("@/hooks/useAgentStream", () => ({
|
|
||||||
useAgentStream: vi.fn(() => ({
|
|
||||||
agents: mockAgents,
|
|
||||||
isConnected: mockAgentStreamConnected,
|
|
||||||
connectionError: null,
|
|
||||||
dismissAgent: mockDismissAgent,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { TerminalPanel } from "./TerminalPanel";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function setTwoSessions(): void {
|
|
||||||
mockSessions = new Map([
|
|
||||||
["session-1", { sessionId: "session-1", name: "Terminal 1", status: "active" }],
|
|
||||||
["session-2", { sessionId: "session-2", name: "Terminal 2", status: "active" }],
|
|
||||||
]);
|
|
||||||
mockActiveSessionId = "session-1";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tests
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("TerminalPanel", () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockSessions = new Map();
|
|
||||||
mockActiveSessionId = null;
|
|
||||||
mockIsConnected = false;
|
|
||||||
mockConnectionError = null;
|
|
||||||
mockRegisterOutputCallback.mockReturnValue(vi.fn());
|
|
||||||
mockAgents = new Map();
|
|
||||||
mockAgentStreamConnected = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Rendering
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("rendering", () => {
|
|
||||||
it("renders the terminal panel", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders with height 280 when open", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
|
||||||
expect(panel).toHaveStyle({ height: "280px" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders with height 0 when closed", () => {
|
|
||||||
const { container } = render(
|
|
||||||
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
|
||||||
);
|
|
||||||
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
|
||||||
expect(panel).toHaveStyle({ height: "0px" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders empty state when no sessions exist", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
// No XTerminal instances should be mounted
|
|
||||||
expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows connecting message in empty state when not connected", () => {
|
|
||||||
mockIsConnected = false;
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByText("Connecting...")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows creating message in empty state when connected", () => {
|
|
||||||
mockIsConnected = true;
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByText("Creating terminal...")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tab bar from sessions
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("tab bar", () => {
|
|
||||||
it("renders a tab for each session", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks the active session tab as selected", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
mockActiveSessionId = "session-2";
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"true"
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"false"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls setActiveSession when a tab is clicked", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" }));
|
|
||||||
expect(mockSetActiveSession).toHaveBeenCalledWith("session-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has tablist role on the tab bar", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// New tab button
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("new tab button", () => {
|
|
||||||
it("renders the new tab button", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls createSession when new tab button is clicked", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "New terminal tab" }));
|
|
||||||
expect(mockCreateSession).toHaveBeenCalledWith(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
expect.objectContaining({ name: expect.any(String) })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Per-tab close button
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("per-tab close button", () => {
|
|
||||||
it("renders a close button for each tab", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("button", { name: "Close Terminal 1" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("button", { name: "Close Terminal 2" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls closeSession with the correct sessionId when tab close is clicked", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close Terminal 1" }));
|
|
||||||
expect(mockCloseSession).toHaveBeenCalledWith("session-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Panel close button
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("panel close button", () => {
|
|
||||||
it("renders the close panel button", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onClose when close panel button is clicked", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
|
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Multi-tab XTerminal rendering
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("multi-tab terminal rendering", () => {
|
|
||||||
it("renders an XTerminal for each session", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const terminals = screen.getAllByTestId("mock-xterminal");
|
|
||||||
expect(terminals).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the active session terminal as visible", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
mockActiveSessionId = "session-1";
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const terminal1 = screen
|
|
||||||
.getAllByTestId("mock-xterminal")
|
|
||||||
.find((el) => el.getAttribute("data-session-id") === "session-1");
|
|
||||||
expect(terminal1).toHaveAttribute("data-visible", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides inactive session terminals", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
mockActiveSessionId = "session-1";
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const terminal2 = screen
|
|
||||||
.getAllByTestId("mock-xterminal")
|
|
||||||
.find((el) => el.getAttribute("data-session-id") === "session-2");
|
|
||||||
expect(terminal2).toHaveAttribute("data-visible", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes sessionStatus to XTerminal", () => {
|
|
||||||
mockSessions = new Map([
|
|
||||||
[
|
|
||||||
"session-1",
|
|
||||||
{ sessionId: "session-1", name: "Terminal 1", status: "exited", exitCode: 0 },
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
mockActiveSessionId = "session-1";
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const terminal = screen.getByTestId("mock-xterminal");
|
|
||||||
expect(terminal).toHaveAttribute("data-status", "exited");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes isVisible=false to all terminals when panel is closed", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
const { container } = render(
|
|
||||||
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
|
||||||
);
|
|
||||||
const terminals = container.querySelectorAll('[data-testid="mock-xterminal"]');
|
|
||||||
terminals.forEach((terminal) => {
|
|
||||||
expect(terminal).toHaveAttribute("data-visible", "false");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Inline tab rename
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("tab rename", () => {
|
|
||||||
it("shows a rename input when a tab is double-clicked", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
|
||||||
expect(screen.getByTestId("tab-rename-input")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls renameSession when rename input loses focus", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
|
||||||
|
|
||||||
const input = screen.getByTestId("tab-rename-input");
|
|
||||||
fireEvent.change(input, { target: { value: "Custom Shell" } });
|
|
||||||
fireEvent.blur(input);
|
|
||||||
|
|
||||||
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "Custom Shell");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls renameSession when Enter is pressed in the rename input", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
|
||||||
|
|
||||||
const input = screen.getByTestId("tab-rename-input");
|
|
||||||
fireEvent.change(input, { target: { value: "New Name" } });
|
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
|
||||||
|
|
||||||
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "New Name");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels rename when Escape is pressed", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
|
||||||
|
|
||||||
const input = screen.getByTestId("tab-rename-input");
|
|
||||||
fireEvent.change(input, { target: { value: "Abandoned Name" } });
|
|
||||||
fireEvent.keyDown(input, { key: "Escape" });
|
|
||||||
|
|
||||||
expect(mockRenameSession).not.toHaveBeenCalled();
|
|
||||||
expect(screen.queryByTestId("tab-rename-input")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Connection error banner
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("connection error", () => {
|
|
||||||
it("shows a connection error banner when connectionError is set", () => {
|
|
||||||
mockConnectionError = "WebSocket connection failed";
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const alert = screen.getByRole("alert");
|
|
||||||
expect(alert).toBeInTheDocument();
|
|
||||||
expect(alert).toHaveTextContent(/WebSocket connection failed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show the error banner when connectionError is null", () => {
|
|
||||||
mockConnectionError = null;
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Accessibility
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("accessibility", () => {
|
|
||||||
it("has aria-hidden=true when closed", () => {
|
|
||||||
const { container } = render(
|
|
||||||
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
|
||||||
);
|
|
||||||
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
|
||||||
expect(panel).toHaveAttribute("aria-hidden", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has aria-hidden=false when open", () => {
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
|
||||||
expect(panel).toHaveAttribute("aria-hidden", "false");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Auto-create session
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("auto-create first session", () => {
|
|
||||||
it("calls createSession when connected and no sessions exist", () => {
|
|
||||||
mockIsConnected = true;
|
|
||||||
mockSessions = new Map();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(mockCreateSession).toHaveBeenCalledWith(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
expect.objectContaining({ name: expect.any(String) })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call createSession when sessions already exist", () => {
|
|
||||||
mockIsConnected = true;
|
|
||||||
setTwoSessions();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call createSession when not connected", () => {
|
|
||||||
mockIsConnected = false;
|
|
||||||
mockSessions = new Map();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call createSession when panel is closed", () => {
|
|
||||||
mockIsConnected = true;
|
|
||||||
mockSessions = new Map();
|
|
||||||
render((<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Agent tab integration
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("agent tab integration", () => {
|
|
||||||
function setOneAgent(status: "spawning" | "running" | "completed" | "error" = "running"): void {
|
|
||||||
mockAgents = new Map([
|
|
||||||
[
|
|
||||||
"agent-1",
|
|
||||||
{
|
|
||||||
agentId: "agent-1",
|
|
||||||
agentType: "worker",
|
|
||||||
status,
|
|
||||||
outputLines: ["Hello from agent\n"],
|
|
||||||
startedAt: Date.now() - 3000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("renders an agent tab when an agent is active", () => {
|
|
||||||
setOneAgent("running");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getAllByTestId("agent-tab")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders no agent tabs when agents map is empty", () => {
|
|
||||||
mockAgents = new Map();
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.queryByTestId("agent-tab")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("agent tab button has the agent type as label", () => {
|
|
||||||
setOneAgent("running");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("agent tab has role=tab", () => {
|
|
||||||
setOneAgent("running");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows dismiss button for completed agents", () => {
|
|
||||||
setOneAgent("completed");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows dismiss button for error agents", () => {
|
|
||||||
setOneAgent("error");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show dismiss button for running agents", () => {
|
|
||||||
setOneAgent("running");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(
|
|
||||||
screen.queryByRole("button", { name: "Dismiss worker agent" })
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show dismiss button for spawning agents", () => {
|
|
||||||
setOneAgent("spawning");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(
|
|
||||||
screen.queryByRole("button", { name: "Dismiss worker agent" })
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls dismissAgent when dismiss button is clicked", () => {
|
|
||||||
setOneAgent("completed");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss worker agent" }));
|
|
||||||
expect(mockDismissAgent).toHaveBeenCalledWith("agent-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders AgentTerminal when agent tab is active", () => {
|
|
||||||
setOneAgent("running");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
// Click the agent tab to make it active
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Agent: worker" }));
|
|
||||||
// AgentTerminal should be rendered (mock shows mock-agent-terminal)
|
|
||||||
expect(screen.getByTestId("mock-agent-terminal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a divider between terminal and agent tabs", () => {
|
|
||||||
setTwoSessions();
|
|
||||||
setOneAgent("running");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
// The divider div is aria-hidden; check it's present in the DOM
|
|
||||||
const tablist = screen.getByRole("tablist");
|
|
||||||
const divider = tablist.querySelector('[aria-hidden="true"][style*="width: 1"]');
|
|
||||||
expect(divider).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("agent tabs show correct data-agent-status", () => {
|
|
||||||
setOneAgent("running");
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
const tab = screen.getByTestId("agent-tab");
|
|
||||||
expect(tab).toHaveAttribute("data-agent-status", "running");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty state not shown when agents exist but no terminal sessions", () => {
|
|
||||||
mockSessions = new Map();
|
|
||||||
setOneAgent("running");
|
|
||||||
mockIsConnected = false;
|
|
||||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
|
||||||
expect(screen.queryByText("Connecting...")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,191 +1,80 @@
|
|||||||
"use client";
|
import type { ReactElement, CSSProperties } from "react";
|
||||||
|
|
||||||
/**
|
export interface TerminalLine {
|
||||||
* TerminalPanel
|
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
|
||||||
*
|
content: string;
|
||||||
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
|
}
|
||||||
* rendering one XTerminal per session and keeping all instances mounted (for
|
|
||||||
* scrollback preservation) while switching visibility with display:none.
|
|
||||||
*
|
|
||||||
* Also renders read-only agent output tabs from the orchestrator SSE stream
|
|
||||||
* via useAgentStream. Agent tabs are automatically added when agents are active
|
|
||||||
* and can be dismissed when completed or errored.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - "+" button to open a new terminal tab
|
|
||||||
* - Per-tab close button (terminal) / dismiss button (agent)
|
|
||||||
* - Double-click tab label for inline rename (terminal tabs only)
|
|
||||||
* - Auto-creates the first terminal session on connect
|
|
||||||
* - Connection error state
|
|
||||||
* - Agent tabs: read-only, auto-appear, dismissable
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
export interface TerminalTab {
|
||||||
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
|
id: string;
|
||||||
import { XTerminal } from "./XTerminal";
|
label: string;
|
||||||
import { AgentTerminal } from "./AgentTerminal";
|
}
|
||||||
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
|
|
||||||
import { useAgentStream } from "@/hooks/useAgentStream";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Types
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export interface TerminalPanelProps {
|
export interface TerminalPanelProps {
|
||||||
/** Whether the panel is visible */
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** Called when the user closes the panel */
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Authentication token for the WebSocket connection */
|
tabs?: TerminalTab[];
|
||||||
token?: string;
|
activeTab?: string;
|
||||||
/** Optional CSS class name */
|
onTabChange?: (id: string) => void;
|
||||||
|
lines?: TerminalLine[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
const defaultTabs: TerminalTab[] = [
|
||||||
// Component
|
{ id: "main", label: "main" },
|
||||||
// ==========================================
|
{ id: "build", label: "build" },
|
||||||
|
{ id: "logs", label: "logs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const blinkKeyframes = `
|
||||||
|
@keyframes ms-terminal-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let blinkStyleInjected = false;
|
||||||
|
|
||||||
|
function ensureBlinkStyle(): void {
|
||||||
|
if (blinkStyleInjected || typeof document === "undefined") return;
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = blinkKeyframes;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
blinkStyleInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineColor(type: TerminalLine["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "prompt":
|
||||||
|
return "var(--success)";
|
||||||
|
case "command":
|
||||||
|
return "var(--text-2)";
|
||||||
|
case "output":
|
||||||
|
return "var(--muted)";
|
||||||
|
case "error":
|
||||||
|
return "var(--danger)";
|
||||||
|
case "warning":
|
||||||
|
return "var(--warn)";
|
||||||
|
case "success":
|
||||||
|
return "var(--success)";
|
||||||
|
default:
|
||||||
|
return "var(--muted)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function TerminalPanel({
|
export function TerminalPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
token = "",
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
lines = [],
|
||||||
className = "",
|
className = "",
|
||||||
}: TerminalPanelProps): ReactElement {
|
}: TerminalPanelProps): ReactElement {
|
||||||
const {
|
ensureBlinkStyle();
|
||||||
sessions,
|
|
||||||
activeSessionId,
|
|
||||||
isConnected,
|
|
||||||
connectionError,
|
|
||||||
createSession,
|
|
||||||
closeSession,
|
|
||||||
renameSession,
|
|
||||||
setActiveSession,
|
|
||||||
sendInput,
|
|
||||||
resize,
|
|
||||||
registerOutputCallback,
|
|
||||||
} = useTerminalSessions({ token });
|
|
||||||
|
|
||||||
// ==========================================
|
const resolvedTabs = tabs ?? defaultTabs;
|
||||||
// Agent stream
|
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const { agents, dismissAgent } = useAgentStream();
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Active tab state (terminal session OR agent)
|
|
||||||
// "terminal:<sessionId>" or "agent:<agentId>"
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
type TabId = string; // prefix-qualified: "terminal:<id>" or "agent:<id>"
|
|
||||||
|
|
||||||
const [activeTabId, setActiveTabId] = useState<TabId | null>(null);
|
|
||||||
|
|
||||||
// Sync activeTabId with the terminal session activeSessionId when no agent tab is selected
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveTabId((prev) => {
|
|
||||||
// If an agent tab is active, don't clobber it
|
|
||||||
if (prev?.startsWith("agent:")) return prev;
|
|
||||||
// Reflect active terminal session
|
|
||||||
if (activeSessionId !== null) return `terminal:${activeSessionId}`;
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}, [activeSessionId]);
|
|
||||||
|
|
||||||
// If the active agent tab is dismissed, fall back to the terminal session
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTabId?.startsWith("agent:")) {
|
|
||||||
const agentId = activeTabId.slice("agent:".length);
|
|
||||||
if (!agents.has(agentId)) {
|
|
||||||
setActiveTabId(activeSessionId !== null ? `terminal:${activeSessionId}` : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [agents, activeTabId, activeSessionId]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Inline rename state
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const [editingTabId, setEditingTabId] = useState<string | null>(null);
|
|
||||||
const [editingName, setEditingName] = useState("");
|
|
||||||
const editInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Focus the rename input when editing starts
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingTabId !== null) {
|
|
||||||
editInputRef.current?.select();
|
|
||||||
}
|
|
||||||
}, [editingTabId]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Auto-create first session on connect
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && isConnected && sessions.size === 0) {
|
|
||||||
createSession({ name: "Terminal 1" });
|
|
||||||
}
|
|
||||||
}, [open, isConnected, sessions.size, createSession]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tab rename helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const commitRename = useCallback((): void => {
|
|
||||||
if (editingTabId !== null) {
|
|
||||||
const trimmed = editingName.trim();
|
|
||||||
if (trimmed.length > 0) {
|
|
||||||
renameSession(editingTabId, trimmed);
|
|
||||||
}
|
|
||||||
setEditingTabId(null);
|
|
||||||
setEditingName("");
|
|
||||||
}
|
|
||||||
}, [editingTabId, editingName, renameSession]);
|
|
||||||
|
|
||||||
const handleTabDoubleClick = useCallback((sessionId: string, currentName: string): void => {
|
|
||||||
setEditingTabId(sessionId);
|
|
||||||
setEditingName(currentName);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRenameKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent<HTMLInputElement>): void => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
commitRename();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setEditingTabId(null);
|
|
||||||
setEditingName("");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commitRename]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Session control helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const handleCreateTab = useCallback((): void => {
|
|
||||||
const tabNumber = sessions.size + 1;
|
|
||||||
createSession({ name: `Terminal ${tabNumber.toString()}` });
|
|
||||||
}, [sessions.size, createSession]);
|
|
||||||
|
|
||||||
const handleCloseTab = useCallback(
|
|
||||||
(sessionId: string): void => {
|
|
||||||
closeSession(sessionId);
|
|
||||||
},
|
|
||||||
[closeSession]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRestart = useCallback(
|
|
||||||
(sessionId: string, name: string): void => {
|
|
||||||
closeSession(sessionId);
|
|
||||||
createSession({ name });
|
|
||||||
},
|
|
||||||
[closeSession, createSession]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Styles
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const panelStyle: CSSProperties = {
|
const panelStyle: CSSProperties = {
|
||||||
background: "var(--bg-deep)",
|
background: "var(--bg-deep)",
|
||||||
@@ -210,40 +99,33 @@ export function TerminalPanel({
|
|||||||
const tabBarStyle: CSSProperties = {
|
const tabBarStyle: CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
alignItems: "center",
|
|
||||||
flex: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionsStyle: CSSProperties = {
|
const actionsStyle: CSSProperties = {
|
||||||
|
marginLeft: "auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
alignItems: "center",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const bodyStyle: CSSProperties = {
|
const bodyStyle: CSSProperties = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: "hidden",
|
overflowY: "auto",
|
||||||
display: "flex",
|
padding: "10px 16px",
|
||||||
flexDirection: "column",
|
fontFamily: "var(--mono)",
|
||||||
minHeight: 0,
|
fontSize: "0.78rem",
|
||||||
position: "relative",
|
lineHeight: 1.6,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
const cursorStyle: CSSProperties = {
|
||||||
// Agent status dot color
|
display: "inline-block",
|
||||||
// ==========================================
|
width: 7,
|
||||||
|
height: 14,
|
||||||
const agentDotColor = (status: string): string => {
|
background: "var(--success)",
|
||||||
if (status === "running" || status === "spawning") return "var(--success)";
|
marginLeft: 2,
|
||||||
if (status === "error") return "var(--danger)";
|
animation: "ms-terminal-blink 1s step-end infinite",
|
||||||
return "var(--muted)";
|
verticalAlign: "text-bottom",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Render
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
@@ -256,315 +138,50 @@ export function TerminalPanel({
|
|||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||||
{/* ---- Terminal session tabs ---- */}
|
{resolvedTabs.map((tab) => {
|
||||||
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
const isActive = tab.id === resolvedActiveTab;
|
||||||
const tabKey = `terminal:${sessionId}`;
|
|
||||||
const isActive = tabKey === activeTabId;
|
|
||||||
const isEditing = sessionId === editingTabId;
|
|
||||||
|
|
||||||
const tabStyle: CSSProperties = {
|
const tabStyle: CSSProperties = {
|
||||||
display: "flex",
|
padding: "3px 10px",
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
padding: "3px 6px 3px 10px",
|
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
color: isActive ? "var(--success)" : "var(--muted)",
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
background: isActive ? "var(--surface)" : "transparent",
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
flexShrink: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={tabKey} style={tabStyle}>
|
<button
|
||||||
{isEditing ? (
|
key={tab.id}
|
||||||
<input
|
role="tab"
|
||||||
ref={editInputRef}
|
aria-selected={isActive}
|
||||||
value={editingName}
|
style={tabStyle}
|
||||||
onChange={(e): void => {
|
onClick={(): void => {
|
||||||
setEditingName(e.target.value);
|
onTabChange?.(tab.id);
|
||||||
}}
|
}}
|
||||||
onBlur={commitRename}
|
onMouseEnter={(e): void => {
|
||||||
onKeyDown={handleRenameKeyDown}
|
if (!isActive) {
|
||||||
data-testid="tab-rename-input"
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "1px solid var(--primary)",
|
|
||||||
borderRadius: 2,
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--text)",
|
|
||||||
width: `${Math.max(editingName.length, 4).toString()}ch`,
|
|
||||||
padding: "0 2px",
|
|
||||||
}}
|
|
||||||
aria-label="Rename terminal tab"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: isActive ? "var(--success)" : "var(--muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
onClick={(): void => {
|
|
||||||
setActiveTabId(tabKey);
|
|
||||||
setActiveSession(sessionId);
|
|
||||||
}}
|
|
||||||
onDoubleClick={(): void => {
|
|
||||||
handleTabDoubleClick(sessionId, sessionInfo.name);
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
if (!isActive) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
if (!isActive) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label={sessionInfo.name}
|
|
||||||
>
|
|
||||||
{sessionInfo.name}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Per-tab close button */}
|
|
||||||
<button
|
|
||||||
aria-label={`Close ${sessionInfo.name}`}
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 3,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "var(--muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
padding: 0,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onClick={(): void => {
|
|
||||||
handleCloseTab(sessionId);
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
}}
|
}
|
||||||
onMouseLeave={(e): void => {
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M1 1L7 7M7 1L1 7"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* New tab button */}
|
|
||||||
<button
|
|
||||||
aria-label="New terminal tab"
|
|
||||||
style={{
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: 4,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "var(--muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
padding: 0,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onClick={handleCreateTab}
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Plus icon */}
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M6 1V11M1 6H11"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* ---- Agent section divider (only when agents exist) ---- */}
|
|
||||||
{agents.size > 0 && (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
width: 1,
|
|
||||||
height: 16,
|
|
||||||
background: "var(--border)",
|
|
||||||
marginLeft: 6,
|
|
||||||
marginRight: 4,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ---- Agent tabs ---- */}
|
|
||||||
{[...agents.entries()].map(([agentId, agentSession]) => {
|
|
||||||
const tabKey = `agent:${agentId}`;
|
|
||||||
const isActive = tabKey === activeTabId;
|
|
||||||
|
|
||||||
const canDismiss =
|
|
||||||
agentSession.status === "completed" || agentSession.status === "error";
|
|
||||||
|
|
||||||
const agentTabStyle: CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
padding: "3px 6px 3px 8px",
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: isActive ? "var(--text)" : "var(--muted)",
|
|
||||||
background: isActive ? "var(--surface)" : "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tabKey}
|
|
||||||
style={agentTabStyle}
|
|
||||||
data-testid="agent-tab"
|
|
||||||
data-agent-id={agentId}
|
|
||||||
data-agent-status={agentSession.status}
|
|
||||||
>
|
>
|
||||||
{/* Status dot */}
|
{tab.label}
|
||||||
<span
|
</button>
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: agentDotColor(agentSession.status),
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Agent tab button — read-only, no rename */}
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: isActive ? "var(--text)" : "var(--muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: 0,
|
|
||||||
maxWidth: 100,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
onClick={(): void => {
|
|
||||||
setActiveTabId(tabKey);
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
if (!isActive) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
if (!isActive) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label={`Agent: ${agentSession.agentType}`}
|
|
||||||
>
|
|
||||||
{agentSession.agentType}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dismiss button — only for completed/error agents */}
|
|
||||||
{canDismiss && (
|
|
||||||
<button
|
|
||||||
aria-label={`Dismiss ${agentSession.agentType} agent`}
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 3,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "var(--muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
padding: 0,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onClick={(): void => {
|
|
||||||
dismissAgent(agentId);
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e): void => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M1 1L7 7M7 1L1 7"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={actionsStyle}>
|
<div style={actionsStyle}>
|
||||||
{/* Close panel button */}
|
|
||||||
<button
|
<button
|
||||||
aria-label="Close terminal"
|
aria-label="Close terminal"
|
||||||
style={{
|
style={{
|
||||||
@@ -591,7 +208,7 @@ export function TerminalPanel({
|
|||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Close icon */}
|
{/* Close icon — simple X using SVG */}
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M1 1L11 11M11 1L1 11"
|
d="M1 1L11 11M11 1L1 11"
|
||||||
@@ -604,90 +221,34 @@ export function TerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connection error banner */}
|
{/* Body */}
|
||||||
{connectionError !== null && (
|
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
||||||
<div
|
{lines.map((line, index) => {
|
||||||
role="alert"
|
const isLast = index === lines.length - 1;
|
||||||
style={{
|
const lineStyle: CSSProperties = {
|
||||||
padding: "4px 16px",
|
display: "flex",
|
||||||
fontSize: "0.75rem",
|
gap: 8,
|
||||||
fontFamily: "var(--mono)",
|
};
|
||||||
color: "var(--danger)",
|
const contentStyle: CSSProperties = {
|
||||||
backgroundColor: "var(--bg-deep)",
|
color: getLineColor(line.type),
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Connection error: {connectionError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Terminal body — keep all XTerminal instances mounted for scrollback */}
|
|
||||||
<div style={bodyStyle}>
|
|
||||||
{/* ---- Terminal session panels ---- */}
|
|
||||||
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
|
||||||
const tabKey = `terminal:${sessionId}`;
|
|
||||||
const isActive = tabKey === activeTabId;
|
|
||||||
const termStyle: CSSProperties = {
|
|
||||||
display: isActive ? "flex" : "none",
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: "column",
|
|
||||||
minHeight: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={tabKey} style={termStyle}>
|
<div key={index} style={lineStyle}>
|
||||||
<XTerminal
|
<span style={contentStyle}>
|
||||||
sessionId={sessionId}
|
{line.content}
|
||||||
sendInput={sendInput}
|
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
||||||
resize={resize}
|
</span>
|
||||||
closeSession={closeSession}
|
|
||||||
registerOutputCallback={registerOutputCallback}
|
|
||||||
isConnected={isConnected}
|
|
||||||
sessionStatus={sessionInfo.status}
|
|
||||||
{...(sessionInfo.exitCode !== undefined ? { exitCode: sessionInfo.exitCode } : {})}
|
|
||||||
isVisible={isActive && open}
|
|
||||||
onRestart={(): void => {
|
|
||||||
handleRestart(sessionId, sessionInfo.name);
|
|
||||||
}}
|
|
||||||
style={{ flex: 1, minHeight: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* ---- Agent session panels ---- */}
|
{/* Show cursor even when no lines */}
|
||||||
{[...agents.entries()].map(([agentId, agentSession]) => {
|
{lines.length === 0 && (
|
||||||
const tabKey = `agent:${agentId}`;
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
const isActive = tabKey === activeTabId;
|
<span style={{ color: "var(--success)" }}>
|
||||||
const agentPanelStyle: CSSProperties = {
|
<span aria-hidden="true" style={cursorStyle} />
|
||||||
display: isActive ? "flex" : "none",
|
</span>
|
||||||
flex: 1,
|
|
||||||
flexDirection: "column",
|
|
||||||
minHeight: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={tabKey} style={agentPanelStyle}>
|
|
||||||
<AgentTerminal agent={agentSession} style={{ flex: 1, minHeight: 0 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Empty state — show only when no terminal sessions AND no agent sessions */}
|
|
||||||
{sessions.size === 0 && agents.size === 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file XTerminal.test.tsx
|
|
||||||
* @description Unit tests for the XTerminal component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mocks — set up before importing components
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Mock xterm packages — they require a DOM canvas not available in jsdom
|
|
||||||
const mockTerminalDispose = vi.fn();
|
|
||||||
const mockTerminalWrite = vi.fn();
|
|
||||||
const mockTerminalClear = vi.fn();
|
|
||||||
const mockTerminalOpen = vi.fn();
|
|
||||||
const mockOnData = vi.fn((_handler: (data: string) => void) => ({ dispose: vi.fn() }));
|
|
||||||
const mockLoadAddon = vi.fn();
|
|
||||||
let mockTerminalCols = 80;
|
|
||||||
let mockTerminalRows = 24;
|
|
||||||
|
|
||||||
const MockTerminal = vi.fn(function MockTerminalConstructor(
|
|
||||||
this: Record<string, unknown>,
|
|
||||||
_options: unknown
|
|
||||||
) {
|
|
||||||
this.open = mockTerminalOpen;
|
|
||||||
this.loadAddon = mockLoadAddon;
|
|
||||||
this.onData = mockOnData;
|
|
||||||
this.write = mockTerminalWrite;
|
|
||||||
this.clear = mockTerminalClear;
|
|
||||||
this.dispose = mockTerminalDispose;
|
|
||||||
this.options = {};
|
|
||||||
Object.defineProperty(this, "cols", {
|
|
||||||
get: () => mockTerminalCols,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(this, "rows", {
|
|
||||||
get: () => mockTerminalRows,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockFitAddonFit = vi.fn();
|
|
||||||
const MockFitAddon = vi.fn(function MockFitAddonConstructor(this: Record<string, unknown>) {
|
|
||||||
this.fit = mockFitAddonFit;
|
|
||||||
});
|
|
||||||
|
|
||||||
const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor(
|
|
||||||
this: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
// no-op
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@xterm/xterm", () => ({
|
|
||||||
Terminal: MockTerminal,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@xterm/addon-fit", () => ({
|
|
||||||
FitAddon: MockFitAddon,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@xterm/addon-web-links", () => ({
|
|
||||||
WebLinksAddon: MockWebLinksAddon,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the CSS import
|
|
||||||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
|
||||||
|
|
||||||
// Mock ResizeObserver
|
|
||||||
const mockObserve = vi.fn();
|
|
||||||
const mockUnobserve = vi.fn();
|
|
||||||
const mockDisconnect = vi.fn();
|
|
||||||
|
|
||||||
vi.stubGlobal(
|
|
||||||
"ResizeObserver",
|
|
||||||
vi.fn(function MockResizeObserver(this: Record<string, unknown>, _callback: unknown) {
|
|
||||||
this.observe = mockObserve;
|
|
||||||
this.unobserve = mockUnobserve;
|
|
||||||
this.disconnect = mockDisconnect;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock MutationObserver
|
|
||||||
const mockMutationObserve = vi.fn();
|
|
||||||
const mockMutationDisconnect = vi.fn();
|
|
||||||
|
|
||||||
vi.stubGlobal(
|
|
||||||
"MutationObserver",
|
|
||||||
vi.fn(function MockMutationObserver(this: Record<string, unknown>, _callback: unknown) {
|
|
||||||
this.observe = mockMutationObserve;
|
|
||||||
this.disconnect = mockMutationDisconnect;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Import component after mocks are set up
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
import { XTerminal } from "./XTerminal";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Default props factory
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const mockSendInput = vi.fn();
|
|
||||||
const mockResize = vi.fn();
|
|
||||||
const mockCloseSession = vi.fn();
|
|
||||||
const mockRegisterOutputCallback = vi.fn(() => vi.fn()); // returns unsubscribe fn
|
|
||||||
const mockOnRestart = vi.fn();
|
|
||||||
|
|
||||||
function makeDefaultProps(
|
|
||||||
overrides: Partial<Parameters<typeof XTerminal>[0]> = {}
|
|
||||||
): Parameters<typeof XTerminal>[0] {
|
|
||||||
return {
|
|
||||||
sessionId: "session-test",
|
|
||||||
sendInput: mockSendInput,
|
|
||||||
resize: mockResize,
|
|
||||||
closeSession: mockCloseSession,
|
|
||||||
registerOutputCallback: mockRegisterOutputCallback,
|
|
||||||
isConnected: false,
|
|
||||||
sessionStatus: "active" as const,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tests
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("XTerminal", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockTerminalCols = 80;
|
|
||||||
mockTerminalRows = 24;
|
|
||||||
mockRegisterOutputCallback.mockReturnValue(vi.fn());
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Rendering
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("rendering", () => {
|
|
||||||
it("renders the terminal container", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the xterm viewport div", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the className prop to the container", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps()} className="custom-class" />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets data-session-id on the container", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps({ sessionId: "my-session" })} />) as ReactElement);
|
|
||||||
expect(screen.getByTestId("xterminal-container")).toHaveAttribute(
|
|
||||||
"data-session-id",
|
|
||||||
"my-session"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows connecting message when not connected and session is active", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps({ isConnected: false })} />) as ReactElement);
|
|
||||||
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show connecting message when connected", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps({ isConnected: true })} />) as ReactElement);
|
|
||||||
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show connecting message when session has exited", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<XTerminal {...makeDefaultProps({ isConnected: false, sessionStatus: "exited" })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Exit overlay
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("exit overlay", () => {
|
|
||||||
it("shows restart button when session has exited", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps({ sessionStatus: "exited" })} />) as ReactElement);
|
|
||||||
expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show restart button when session is active", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps({ sessionStatus: "active" })} />) as ReactElement);
|
|
||||||
expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows exit code in restart button when provided", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", exitCode: 1 })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onRestart when restart button is clicked", () => {
|
|
||||||
render(
|
|
||||||
(
|
|
||||||
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", onRestart: mockOnRestart })} />
|
|
||||||
) as ReactElement
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /restart terminal/i }));
|
|
||||||
expect(mockOnRestart).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Output callback registration
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("registerOutputCallback", () => {
|
|
||||||
it("registers a callback for its sessionId on mount", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps({ sessionId: "test-session" })} />) as ReactElement);
|
|
||||||
expect(mockRegisterOutputCallback).toHaveBeenCalledWith("test-session", expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls the returned unsubscribe function on unmount", () => {
|
|
||||||
const unsubscribe = vi.fn();
|
|
||||||
mockRegisterOutputCallback.mockReturnValue(unsubscribe);
|
|
||||||
|
|
||||||
const { unmount } = render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(unsubscribe).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Accessibility
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("accessibility", () => {
|
|
||||||
it("has an accessible region role", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
|
||||||
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Visibility
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("isVisible", () => {
|
|
||||||
it("renders with isVisible=true by default", () => {
|
|
||||||
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
|
||||||
// Container is present; isVisible affects re-fit timing
|
|
||||||
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* XTerminal component
|
|
||||||
*
|
|
||||||
* Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions)
|
|
||||||
* owns the WebSocket connection and session lifecycle. This component receives the
|
|
||||||
* sessionId and control functions as props and registers for output data specific
|
|
||||||
* to its session.
|
|
||||||
*
|
|
||||||
* Handles resize, copy/paste, theme, exit overlay, and reconnect.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { ReactElement, CSSProperties } from "react";
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
|
||||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
|
||||||
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
|
|
||||||
import type { SessionStatus } from "@/hooks/useTerminalSessions";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Types
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export interface XTerminalProps {
|
|
||||||
/** Session identifier (provided by useTerminalSessions) */
|
|
||||||
sessionId: string;
|
|
||||||
/** Send keyboard input to this session */
|
|
||||||
sendInput: (sessionId: string, data: string) => void;
|
|
||||||
/** Notify the server of a terminal resize */
|
|
||||||
resize: (sessionId: string, cols: number, rows: number) => void;
|
|
||||||
/** Close this PTY session */
|
|
||||||
closeSession: (sessionId: string) => void;
|
|
||||||
/**
|
|
||||||
* Register a callback to receive output for this session.
|
|
||||||
* Returns an unsubscribe function.
|
|
||||||
*/
|
|
||||||
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
|
|
||||||
/** Whether the WebSocket is currently connected */
|
|
||||||
isConnected: boolean;
|
|
||||||
/** Current PTY process status */
|
|
||||||
sessionStatus: SessionStatus;
|
|
||||||
/** Exit code, populated when sessionStatus === 'exited' */
|
|
||||||
exitCode?: number;
|
|
||||||
/**
|
|
||||||
* Called when the user clicks the restart button after the session has exited.
|
|
||||||
* The parent is responsible for closing the old session and creating a new one.
|
|
||||||
*/
|
|
||||||
onRestart?: () => void;
|
|
||||||
/** Optional CSS class name for the outer container */
|
|
||||||
className?: string;
|
|
||||||
/** Optional inline styles for the outer container */
|
|
||||||
style?: CSSProperties;
|
|
||||||
/** Whether the terminal is visible (used to trigger re-fit on tab switch) */
|
|
||||||
isVisible?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Theme helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a CSS variable value from :root via computed styles.
|
|
||||||
* Falls back to the provided default value if not available (e.g., during SSR).
|
|
||||||
*/
|
|
||||||
function getCssVar(varName: string, fallback: string): string {
|
|
||||||
if (typeof document === "undefined") return fallback;
|
|
||||||
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
|
||||||
return value.length > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an xterm.js ITheme object from the current design system CSS variables.
|
|
||||||
*/
|
|
||||||
function buildXtermTheme(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
background: getCssVar("--bg-deep", "#080b12"),
|
|
||||||
foreground: getCssVar("--text", "#eef3ff"),
|
|
||||||
cursor: getCssVar("--success", "#14b8a6"),
|
|
||||||
cursorAccent: getCssVar("--bg-deep", "#080b12"),
|
|
||||||
selectionBackground: `${getCssVar("--primary", "#2f80ff")}40`,
|
|
||||||
selectionForeground: getCssVar("--text", "#eef3ff"),
|
|
||||||
selectionInactiveBackground: `${getCssVar("--muted", "#8f9db7")}30`,
|
|
||||||
// Standard ANSI colors mapped to design system
|
|
||||||
black: getCssVar("--bg-deep", "#080b12"),
|
|
||||||
red: getCssVar("--danger", "#e5484d"),
|
|
||||||
green: getCssVar("--success", "#14b8a6"),
|
|
||||||
yellow: getCssVar("--warn", "#f59e0b"),
|
|
||||||
blue: getCssVar("--primary", "#2f80ff"),
|
|
||||||
magenta: getCssVar("--purple", "#8b5cf6"),
|
|
||||||
cyan: "#06b6d4",
|
|
||||||
white: getCssVar("--text-2", "#c5d0e6"),
|
|
||||||
brightBlack: getCssVar("--muted", "#8f9db7"),
|
|
||||||
brightRed: "#f06a6f",
|
|
||||||
brightGreen: "#2dd4bf",
|
|
||||||
brightYellow: "#fbbf24",
|
|
||||||
brightBlue: "#56a0ff",
|
|
||||||
brightMagenta: "#a78bfa",
|
|
||||||
brightCyan: "#22d3ee",
|
|
||||||
brightWhite: getCssVar("--text", "#eef3ff"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Component
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* XTerminal renders a real PTY terminal powered by xterm.js.
|
|
||||||
* The parent provides the sessionId and control functions; this component
|
|
||||||
* registers for output data and manages the xterm.js instance lifecycle.
|
|
||||||
*/
|
|
||||||
export function XTerminal({
|
|
||||||
sessionId,
|
|
||||||
sendInput,
|
|
||||||
resize,
|
|
||||||
closeSession: _closeSession,
|
|
||||||
registerOutputCallback,
|
|
||||||
isConnected,
|
|
||||||
sessionStatus,
|
|
||||||
exitCode,
|
|
||||||
onRestart,
|
|
||||||
className = "",
|
|
||||||
style,
|
|
||||||
isVisible = true,
|
|
||||||
}: XTerminalProps): ReactElement {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const terminalRef = useRef<XTerm | null>(null);
|
|
||||||
const fitAddonRef = useRef<XFitAddon | null>(null);
|
|
||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
|
||||||
const isTerminalMountedRef = useRef(false);
|
|
||||||
|
|
||||||
const hasExited = sessionStatus === "exited";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Fit helper
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const fitAndResize = useCallback((): void => {
|
|
||||||
const fitAddon = fitAddonRef.current;
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
if (!fitAddon || !terminal) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
fitAddon.fit();
|
|
||||||
resize(sessionId, terminal.cols, terminal.rows);
|
|
||||||
} catch {
|
|
||||||
// Ignore fit errors (e.g., when container has zero dimensions)
|
|
||||||
}
|
|
||||||
}, [resize, sessionId]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mount xterm.js terminal (client-only)
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current || isTerminalMountedRef.current) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const mountTerminal = async (): Promise<void> => {
|
|
||||||
// Dynamic imports ensure DOM-dependent xterm.js modules are never loaded server-side
|
|
||||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
|
||||||
import("@xterm/xterm"),
|
|
||||||
import("@xterm/addon-fit"),
|
|
||||||
import("@xterm/addon-web-links"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (cancelled || !containerRef.current) return;
|
|
||||||
|
|
||||||
const theme = buildXtermTheme();
|
|
||||||
|
|
||||||
const terminal = new Terminal({
|
|
||||||
fontFamily: "var(--mono, 'Fira Code', 'Cascadia Code', monospace)",
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
cursorBlink: true,
|
|
||||||
cursorStyle: "block",
|
|
||||||
scrollback: 10000,
|
|
||||||
theme,
|
|
||||||
allowTransparency: false,
|
|
||||||
convertEol: true,
|
|
||||||
// Accessibility
|
|
||||||
screenReaderMode: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
|
||||||
const webLinksAddon = new WebLinksAddon();
|
|
||||||
|
|
||||||
terminal.loadAddon(fitAddon);
|
|
||||||
terminal.loadAddon(webLinksAddon);
|
|
||||||
|
|
||||||
terminal.open(containerRef.current);
|
|
||||||
|
|
||||||
terminalRef.current = terminal;
|
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
isTerminalMountedRef.current = true;
|
|
||||||
|
|
||||||
// Initial fit
|
|
||||||
try {
|
|
||||||
fitAddon.fit();
|
|
||||||
} catch {
|
|
||||||
// Container might not have dimensions yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up ResizeObserver for automatic re-fitting
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
fitAndResize();
|
|
||||||
});
|
|
||||||
observer.observe(containerRef.current);
|
|
||||||
resizeObserverRef.current = observer;
|
|
||||||
};
|
|
||||||
|
|
||||||
void mountTerminal();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
// Intentionally empty dep array — mount once only
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Register output callback for this session
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unregister = registerOutputCallback(sessionId, (data: string) => {
|
|
||||||
terminalRef.current?.write(data);
|
|
||||||
});
|
|
||||||
return unregister;
|
|
||||||
}, [sessionId, registerOutputCallback]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Re-fit when visibility changes
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
// Small delay allows CSS transitions to complete before fitting
|
|
||||||
const id = setTimeout(fitAndResize, 50);
|
|
||||||
return (): void => {
|
|
||||||
clearTimeout(id);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [isVisible, fitAndResize]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Wire terminal input → sendInput
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
if (!terminal) return;
|
|
||||||
|
|
||||||
const disposable = terminal.onData((data: string): void => {
|
|
||||||
sendInput(sessionId, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
disposable.dispose();
|
|
||||||
};
|
|
||||||
}, [sendInput, sessionId]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Update xterm theme when data-theme attribute changes
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
if (terminal) {
|
|
||||||
terminal.options.theme = buildXtermTheme();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["data-theme"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Cleanup on unmount
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return (): void => {
|
|
||||||
// Cleanup ResizeObserver
|
|
||||||
resizeObserverRef.current?.disconnect();
|
|
||||||
resizeObserverRef.current = null;
|
|
||||||
|
|
||||||
// Dispose xterm terminal
|
|
||||||
terminalRef.current?.dispose();
|
|
||||||
terminalRef.current = null;
|
|
||||||
fitAddonRef.current = null;
|
|
||||||
isTerminalMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Restart handler
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const handleRestart = useCallback((): void => {
|
|
||||||
const terminal = terminalRef.current;
|
|
||||||
if (terminal) {
|
|
||||||
terminal.clear();
|
|
||||||
}
|
|
||||||
// Notify parent to close old session and create a new one
|
|
||||||
onRestart?.();
|
|
||||||
}, [onRestart]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Render
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const containerStyle: CSSProperties = {
|
|
||||||
flex: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
backgroundColor: "var(--bg-deep)",
|
|
||||||
...style,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
style={containerStyle}
|
|
||||||
role="region"
|
|
||||||
aria-label="Terminal"
|
|
||||||
data-testid="xterminal-container"
|
|
||||||
data-session-id={sessionId}
|
|
||||||
>
|
|
||||||
{/* Status bar — show when not connected and not exited */}
|
|
||||||
{!isConnected && !hasExited && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
padding: "4px 12px",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--warn)",
|
|
||||||
backgroundColor: "var(--bg-deep)",
|
|
||||||
zIndex: 10,
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Connecting to terminal...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Exit overlay */}
|
|
||||||
{hasExited && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 8,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
padding: "4px 12px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text)",
|
|
||||||
backgroundColor: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onClick={handleRestart}
|
|
||||||
>
|
|
||||||
Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* xterm.js render target */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
padding: "4px 8px",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
data-testid="xterm-viewport"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,2 @@
|
|||||||
export type { TerminalPanelProps } from "./TerminalPanel";
|
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
||||||
export { TerminalPanel } from "./TerminalPanel";
|
export { TerminalPanel } from "./TerminalPanel";
|
||||||
export type { XTerminalProps } from "./XTerminal";
|
|
||||||
export { XTerminal } from "./XTerminal";
|
|
||||||
export type { AgentTerminalProps } from "./AgentTerminal";
|
|
||||||
export { AgentTerminal } from "./AgentTerminal";
|
|
||||||
|
|||||||
@@ -1,542 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file useAgentStream.test.ts
|
|
||||||
* @description Unit tests for the useAgentStream hook
|
|
||||||
*
|
|
||||||
* Tests cover:
|
|
||||||
* - SSE event parsing (agent:spawned, agent:output, agent:completed, agent:error)
|
|
||||||
* - Agent lifecycle state transitions
|
|
||||||
* - Auto-reconnect behavior on connection loss
|
|
||||||
* - Cleanup on unmount
|
|
||||||
* - Dismiss agent
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { renderHook, act } from "@testing-library/react";
|
|
||||||
import { useAgentStream } from "../useAgentStream";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mock EventSource
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface MockEventSourceInstance {
|
|
||||||
url: string;
|
|
||||||
onopen: (() => void) | null;
|
|
||||||
onerror: ((event: Event) => void) | null;
|
|
||||||
onmessage: ((event: MessageEvent) => void) | null;
|
|
||||||
close: ReturnType<typeof vi.fn>;
|
|
||||||
addEventListener: ReturnType<typeof vi.fn>;
|
|
||||||
dispatchEvent: (type: string, data: string) => void;
|
|
||||||
_listeners: Map<string, ((event: MessageEvent<string>) => void)[]>;
|
|
||||||
readyState: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mockEventSourceInstances: MockEventSourceInstance[] = [];
|
|
||||||
|
|
||||||
const MockEventSource = vi.fn(function (this: MockEventSourceInstance, url: string): void {
|
|
||||||
this.url = url;
|
|
||||||
this.onopen = null;
|
|
||||||
this.onerror = null;
|
|
||||||
this.onmessage = null;
|
|
||||||
this.close = vi.fn();
|
|
||||||
this.readyState = 0;
|
|
||||||
this._listeners = new Map();
|
|
||||||
|
|
||||||
this.addEventListener = vi.fn(
|
|
||||||
(type: string, handler: (event: MessageEvent<string>) => void): void => {
|
|
||||||
if (!this._listeners.has(type)) {
|
|
||||||
this._listeners.set(type, []);
|
|
||||||
}
|
|
||||||
const list = this._listeners.get(type);
|
|
||||||
if (list) list.push(handler);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.dispatchEvent = (type: string, data: string): void => {
|
|
||||||
const handlers = this._listeners.get(type) ?? [];
|
|
||||||
const event = new MessageEvent(type, { data });
|
|
||||||
for (const handler of handlers) {
|
|
||||||
handler(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mockEventSourceInstances.push(this);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add static constants
|
|
||||||
Object.assign(MockEventSource, {
|
|
||||||
CONNECTING: 0,
|
|
||||||
OPEN: 1,
|
|
||||||
CLOSED: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("EventSource", MockEventSource);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function getLatestES(): MockEventSourceInstance {
|
|
||||||
const es = mockEventSourceInstances[mockEventSourceInstances.length - 1];
|
|
||||||
if (!es) throw new Error("No EventSource instance created");
|
|
||||||
return es;
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerOpen(): void {
|
|
||||||
const es = getLatestES();
|
|
||||||
if (es.onopen) es.onopen();
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerError(): void {
|
|
||||||
const es = getLatestES();
|
|
||||||
if (es.onerror) es.onerror(new Event("error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitEvent(type: string, data: unknown): void {
|
|
||||||
const es = getLatestES();
|
|
||||||
es.dispatchEvent(type, JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tests
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("useAgentStream", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
mockEventSourceInstances = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.runAllTimers();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Initialization
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("initialization", () => {
|
|
||||||
it("creates an EventSource connecting to /api/orchestrator/events", () => {
|
|
||||||
renderHook(() => useAgentStream());
|
|
||||||
expect(MockEventSource).toHaveBeenCalledWith("/api/orchestrator/events");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts with isConnected=false before onopen fires", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts with an empty agents map", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
expect(result.current.agents.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets isConnected=true when EventSource opens", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
});
|
|
||||||
expect(result.current.isConnected).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears connectionError when EventSource opens", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
// Trigger an error first to set connectionError
|
|
||||||
act(() => {
|
|
||||||
triggerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start a fresh reconnect and open it
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.connectionError).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// SSE event: agent:spawned
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("agent:spawned event", () => {
|
|
||||||
it("adds an agent with status=spawning", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker", jobId: "job-abc" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.has("agent-1")).toBe(true);
|
|
||||||
expect(result.current.agents.get("agent-1")?.status).toBe("spawning");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets agentType from the type field", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "planner" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.agentType).toBe("planner");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults agentType to 'agent' when type is missing", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-2" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-2")?.agentType).toBe("agent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores jobId when present", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-3", type: "worker", jobId: "job-xyz" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-3")?.jobId).toBe("job-xyz");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts with empty outputLines", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.outputLines).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// SSE event: agent:output
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("agent:output event", () => {
|
|
||||||
it("appends output to the agent's outputLines", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:output", { agentId: "agent-1", data: "Hello world\n" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.outputLines).toContain("Hello world\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("transitions status from spawning to running on first output", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:output", { agentId: "agent-1", data: "Starting...\n" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.status).toBe("running");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accumulates multiple output lines", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:output", { agentId: "agent-1", data: "Line 1\n" });
|
|
||||||
emitEvent("agent:output", { agentId: "agent-1", data: "Line 2\n" });
|
|
||||||
emitEvent("agent:output", { agentId: "agent-1", data: "Line 3\n" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = result.current.agents.get("agent-1")?.outputLines ?? [];
|
|
||||||
expect(lines).toHaveLength(3);
|
|
||||||
expect(lines[0]).toBe("Line 1\n");
|
|
||||||
expect(lines[1]).toBe("Line 2\n");
|
|
||||||
expect(lines[2]).toBe("Line 3\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new agent entry if output arrives before spawned event", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:output", { agentId: "unknown-agent", data: "Surprise output\n" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.has("unknown-agent")).toBe(true);
|
|
||||||
expect(result.current.agents.get("unknown-agent")?.status).toBe("running");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// SSE event: agent:completed
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("agent:completed event", () => {
|
|
||||||
it("sets status to completed", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:output", { agentId: "agent-1", data: "Working...\n" });
|
|
||||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.status).toBe("completed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores the exitCode", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 42 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.exitCode).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets endedAt timestamp", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores completed event for unknown agent", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:completed", { agentId: "ghost-agent", exitCode: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.has("ghost-agent")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// SSE event: agent:error
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("agent:error event", () => {
|
|
||||||
it("sets status to error", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:error", { agentId: "agent-1", error: "Out of memory" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.status).toBe("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores the error message", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:error", { agentId: "agent-1", error: "Segfault" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.errorMessage).toBe("Segfault");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets endedAt on error", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:error", { agentId: "agent-1", error: "Crash" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.get("agent-1")?.endedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores error event for unknown agent", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:error", { agentId: "ghost-agent", error: "Crash" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.has("ghost-agent")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Reconnect behavior
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("auto-reconnect", () => {
|
|
||||||
it("sets isConnected=false on error", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
triggerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets connectionError on error", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
triggerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.connectionError).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new EventSource after reconnect delay", () => {
|
|
||||||
renderHook(() => useAgentStream());
|
|
||||||
const initialCount = mockEventSourceInstances.length;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
triggerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(1500); // initial backoff = 1000ms
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockEventSourceInstances.length).toBeGreaterThan(initialCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes the old EventSource before reconnecting", () => {
|
|
||||||
renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
triggerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
const closedInstance = mockEventSourceInstances[0];
|
|
||||||
expect(closedInstance?.close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Cleanup on unmount
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("cleanup on unmount", () => {
|
|
||||||
it("closes EventSource when the hook is unmounted", () => {
|
|
||||||
const { unmount } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
const es = getLatestES();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(es.close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not attempt to reconnect after unmount", () => {
|
|
||||||
const { unmount } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
triggerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
const countBeforeUnmount = mockEventSourceInstances.length;
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// No new instances created after unmount
|
|
||||||
expect(mockEventSourceInstances.length).toBe(countBeforeUnmount);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Dismiss agent
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("dismissAgent", () => {
|
|
||||||
it("removes the agent from the map", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
emitEvent("agent:completed", { agentId: "agent-1", exitCode: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.dismissAgent("agent-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.has("agent-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op for unknown agentId", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
emitEvent("agent:spawned", { agentId: "agent-1", type: "worker" });
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.dismissAgent("nonexistent-agent");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.agents.has("agent-1")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Malformed event handling
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("malformed events", () => {
|
|
||||||
it("ignores malformed JSON without throwing", () => {
|
|
||||||
const { result } = renderHook(() => useAgentStream());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
triggerOpen();
|
|
||||||
// Dispatch raw bad JSON
|
|
||||||
const es = getLatestES();
|
|
||||||
es.dispatchEvent("agent:spawned", "NOT JSON {{{");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should not crash, agents map stays empty
|
|
||||||
expect(result.current.agents.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useAgentStream hook
|
|
||||||
*
|
|
||||||
* Connects to the orchestrator SSE event stream at /api/orchestrator/events
|
|
||||||
* and maintains a Map of agentId → AgentSession with accumulated output,
|
|
||||||
* status, and lifecycle metadata.
|
|
||||||
*
|
|
||||||
* SSE event types consumed:
|
|
||||||
* - agent:spawned — { agentId, type, jobId }
|
|
||||||
* - agent:output — { agentId, data } (stdout/stderr lines)
|
|
||||||
* - agent:completed — { agentId, exitCode, result }
|
|
||||||
* - agent:error — { agentId, error }
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Auto-reconnect with exponential backoff on connection loss
|
|
||||||
* - Cleans up EventSource on unmount
|
|
||||||
* - Accumulates output lines per agent
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Types
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export type AgentStatus = "spawning" | "running" | "completed" | "error";
|
|
||||||
|
|
||||||
export interface AgentSession {
|
|
||||||
/** Agent identifier from the orchestrator */
|
|
||||||
agentId: string;
|
|
||||||
/** Agent type or name (e.g., "worker", "planner") */
|
|
||||||
agentType: string;
|
|
||||||
/** Optional job ID this agent is associated with */
|
|
||||||
jobId?: string;
|
|
||||||
/** Current lifecycle status */
|
|
||||||
status: AgentStatus;
|
|
||||||
/** Accumulated output lines (stdout/stderr) */
|
|
||||||
outputLines: string[];
|
|
||||||
/** Timestamp when the agent was spawned */
|
|
||||||
startedAt: number;
|
|
||||||
/** Timestamp when the agent completed or errored */
|
|
||||||
endedAt?: number;
|
|
||||||
/** Exit code from agent:completed event */
|
|
||||||
exitCode?: number;
|
|
||||||
/** Error message from agent:error event */
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseAgentStreamReturn {
|
|
||||||
/** Map of agentId → AgentSession */
|
|
||||||
agents: Map<string, AgentSession>;
|
|
||||||
/** Whether the SSE stream is currently connected */
|
|
||||||
isConnected: boolean;
|
|
||||||
/** Connection error message, if any */
|
|
||||||
connectionError: string | null;
|
|
||||||
/** Dismiss (remove) an agent tab by agentId */
|
|
||||||
dismissAgent: (agentId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// SSE payload shapes
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface SpawnedPayload {
|
|
||||||
agentId: string;
|
|
||||||
type?: string;
|
|
||||||
jobId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OutputPayload {
|
|
||||||
agentId: string;
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CompletedPayload {
|
|
||||||
agentId: string;
|
|
||||||
exitCode?: number;
|
|
||||||
result?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorPayload {
|
|
||||||
agentId: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Backoff config
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const RECONNECT_BASE_MS = 1_000;
|
|
||||||
const RECONNECT_MAX_MS = 30_000;
|
|
||||||
const RECONNECT_MULTIPLIER = 2;
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Hook
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects to the orchestrator SSE stream and tracks all agent sessions.
|
|
||||||
*
|
|
||||||
* @returns Agent sessions map, connection status, and dismiss callback
|
|
||||||
*/
|
|
||||||
export function useAgentStream(): UseAgentStreamReturn {
|
|
||||||
const [agents, setAgents] = useState<Map<string, AgentSession>>(new Map());
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const reconnectDelayRef = useRef<number>(RECONNECT_BASE_MS);
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Agent state update helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const handleAgentSpawned = useCallback((payload: SpawnedPayload): void => {
|
|
||||||
setAgents((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(payload.agentId, {
|
|
||||||
agentId: payload.agentId,
|
|
||||||
agentType: payload.type ?? "agent",
|
|
||||||
...(payload.jobId !== undefined ? { jobId: payload.jobId } : {}),
|
|
||||||
status: "spawning",
|
|
||||||
outputLines: [],
|
|
||||||
startedAt: Date.now(),
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAgentOutput = useCallback((payload: OutputPayload): void => {
|
|
||||||
setAgents((prev) => {
|
|
||||||
const existing = prev.get(payload.agentId);
|
|
||||||
if (!existing) {
|
|
||||||
// First output for an agent we haven't seen spawned — create it
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(payload.agentId, {
|
|
||||||
agentId: payload.agentId,
|
|
||||||
agentType: "agent",
|
|
||||||
status: "running",
|
|
||||||
outputLines: [payload.data],
|
|
||||||
startedAt: Date.now(),
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(payload.agentId, {
|
|
||||||
...existing,
|
|
||||||
status: existing.status === "spawning" ? "running" : existing.status,
|
|
||||||
outputLines: [...existing.outputLines, payload.data],
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAgentCompleted = useCallback((payload: CompletedPayload): void => {
|
|
||||||
setAgents((prev) => {
|
|
||||||
const existing = prev.get(payload.agentId);
|
|
||||||
if (!existing) return prev;
|
|
||||||
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(payload.agentId, {
|
|
||||||
...existing,
|
|
||||||
status: "completed",
|
|
||||||
endedAt: Date.now(),
|
|
||||||
...(payload.exitCode !== undefined ? { exitCode: payload.exitCode } : {}),
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAgentError = useCallback((payload: ErrorPayload): void => {
|
|
||||||
setAgents((prev) => {
|
|
||||||
const existing = prev.get(payload.agentId);
|
|
||||||
if (!existing) return prev;
|
|
||||||
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(payload.agentId, {
|
|
||||||
...existing,
|
|
||||||
status: "error",
|
|
||||||
endedAt: Date.now(),
|
|
||||||
...(payload.error !== undefined ? { errorMessage: payload.error } : {}),
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// SSE connection
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const connect = useCallback((): void => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
if (typeof EventSource === "undefined") return;
|
|
||||||
|
|
||||||
// Clean up any existing connection
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = new EventSource("/api/orchestrator/events");
|
|
||||||
eventSourceRef.current = es;
|
|
||||||
|
|
||||||
es.onopen = (): void => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
setIsConnected(true);
|
|
||||||
setConnectionError(null);
|
|
||||||
reconnectDelayRef.current = RECONNECT_BASE_MS;
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onerror = (): void => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
setIsConnected(false);
|
|
||||||
|
|
||||||
es.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
|
|
||||||
// Schedule reconnect with backoff
|
|
||||||
const delay = reconnectDelayRef.current;
|
|
||||||
reconnectDelayRef.current = Math.min(delay * RECONNECT_MULTIPLIER, RECONNECT_MAX_MS);
|
|
||||||
|
|
||||||
const delaySecs = Math.round(delay / 1000).toString();
|
|
||||||
setConnectionError(`SSE connection lost. Reconnecting in ${delaySecs}s...`);
|
|
||||||
|
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
es.addEventListener("agent:spawned", (event: MessageEvent<string>) => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data) as SpawnedPayload;
|
|
||||||
handleAgentSpawned(payload);
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed events
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener("agent:output", (event: MessageEvent<string>) => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data) as OutputPayload;
|
|
||||||
handleAgentOutput(payload);
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed events
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener("agent:completed", (event: MessageEvent<string>) => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data) as CompletedPayload;
|
|
||||||
handleAgentCompleted(payload);
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed events
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
es.addEventListener("agent:error", (event: MessageEvent<string>) => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.data) as ErrorPayload;
|
|
||||||
handleAgentError(payload);
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed events
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [handleAgentSpawned, handleAgentOutput, handleAgentCompleted, handleAgentError]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mount / unmount
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
|
|
||||||
if (reconnectTimerRef.current !== null) {
|
|
||||||
clearTimeout(reconnectTimerRef.current);
|
|
||||||
reconnectTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [connect]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Dismiss agent
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const dismissAgent = useCallback((agentId: string): void => {
|
|
||||||
setAgents((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(agentId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
agents,
|
|
||||||
isConnected,
|
|
||||||
connectionError,
|
|
||||||
dismissAgent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -294,7 +294,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
const response = await sendChatMessage(request);
|
const response = await sendChatMessage(request);
|
||||||
|
|
||||||
const assistantMessage: Message = {
|
const assistantMessage: Message = {
|
||||||
id: `assistant-${Date.now().toString()}`,
|
id: `assistant-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: response.message.content,
|
content: response.message.content,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -328,7 +328,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorMessage: Message = {
|
const errorMessage: Message = {
|
||||||
id: `error-${String(Date.now())}`,
|
id: `error-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Something went wrong. Please try again.",
|
content: "Something went wrong. Please try again.",
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for useOrchestratorCommands hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { renderHook, act } from "@testing-library/react";
|
|
||||||
import { useOrchestratorCommands } from "./useOrchestratorCommands";
|
|
||||||
import type { Message } from "./useChat";
|
|
||||||
|
|
||||||
// Mock fetch globally
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
global.fetch = mockFetch;
|
|
||||||
|
|
||||||
function makeOkResponse(data: unknown): Response {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: () => Promise.resolve(data),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(data)),
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run executeCommand and return the result synchronously after act() */
|
|
||||||
async function runCommand(
|
|
||||||
executeCommand: (content: string) => Promise<Message | null>,
|
|
||||||
content: string
|
|
||||||
): Promise<Message | null> {
|
|
||||||
let msg: Message | null = null;
|
|
||||||
await act(async () => {
|
|
||||||
msg = await executeCommand(content);
|
|
||||||
});
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useOrchestratorCommands", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isCommand", () => {
|
|
||||||
it("returns true for messages starting with /", () => {
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
expect(result.current.isCommand("/status")).toBe(true);
|
|
||||||
expect(result.current.isCommand("/agents")).toBe(true);
|
|
||||||
expect(result.current.isCommand("/help")).toBe(true);
|
|
||||||
expect(result.current.isCommand(" /status")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for regular messages", () => {
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
expect(result.current.isCommand("hello")).toBe(false);
|
|
||||||
expect(result.current.isCommand("tell me about /status")).toBe(false);
|
|
||||||
expect(result.current.isCommand("")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("executeCommand", () => {
|
|
||||||
describe("/help", () => {
|
|
||||||
it("returns help message without network calls", async () => {
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/help");
|
|
||||||
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
expect(msg).not.toBeNull();
|
|
||||||
expect(msg?.role).toBe("assistant");
|
|
||||||
expect(msg?.content).toContain("/status");
|
|
||||||
expect(msg?.content).toContain("/agents");
|
|
||||||
expect(msg?.content).toContain("/jobs");
|
|
||||||
expect(msg?.content).toContain("/pause");
|
|
||||||
expect(msg?.content).toContain("/resume");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns message with id and createdAt", async () => {
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/help");
|
|
||||||
|
|
||||||
expect(msg?.id).toBeDefined();
|
|
||||||
expect(msg?.createdAt).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/status", () => {
|
|
||||||
it("calls /api/orchestrator/health and returns formatted status", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
|
||||||
makeOkResponse({ status: "ready", version: "1.2.3", uptime: 3661 })
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/health", { method: "GET" });
|
|
||||||
expect(msg?.role).toBe("assistant");
|
|
||||||
expect(msg?.content).toContain("Ready");
|
|
||||||
expect(msg?.content).toContain("1.2.3");
|
|
||||||
expect(msg?.content).toContain("1h");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows Not Ready when status is not ready", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ status: "not-ready" }));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("Not Ready");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles network error gracefully", async () => {
|
|
||||||
mockFetch.mockRejectedValueOnce(new Error("Connection refused"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
|
||||||
|
|
||||||
expect(msg?.role).toBe("assistant");
|
|
||||||
expect(msg?.content).toContain("Error");
|
|
||||||
expect(msg?.content).toContain("Connection refused");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error from API response", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
|
||||||
makeOkResponse({ error: "ORCHESTRATOR_API_KEY is not configured" })
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/status");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("Not reachable");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/agents", () => {
|
|
||||||
it("calls /api/orchestrator/agents and returns agent table", async () => {
|
|
||||||
const agents = [
|
|
||||||
{ id: "agent-1", status: "active", type: "codex", startedAt: "2026-02-25T10:00:00Z" },
|
|
||||||
{
|
|
||||||
id: "agent-2",
|
|
||||||
agentStatus: "TERMINATED",
|
|
||||||
channel: "claude",
|
|
||||||
startedAt: "2026-02-25T09:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockFetch.mockResolvedValueOnce(makeOkResponse(agents));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/agents", { method: "GET" });
|
|
||||||
expect(msg?.content).toContain("agent-1");
|
|
||||||
expect(msg?.content).toContain("agent-2");
|
|
||||||
expect(msg?.content).toContain("TERMINATED");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty agent list", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(makeOkResponse([]));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("No agents currently running");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles agents in nested object", async () => {
|
|
||||||
const data = {
|
|
||||||
agents: [{ id: "agent-nested", status: "active" }],
|
|
||||||
};
|
|
||||||
mockFetch.mockResolvedValueOnce(makeOkResponse(data));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("agent-nested");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles network error gracefully", async () => {
|
|
||||||
mockFetch.mockRejectedValueOnce(new Error("Timeout"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/agents");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("Error");
|
|
||||||
expect(msg?.content).toContain("Timeout");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/jobs", () => {
|
|
||||||
it("calls /api/orchestrator/queue/stats", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
|
||||||
makeOkResponse({ pending: 3, active: 1, completed: 42, failed: 0 })
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/jobs");
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/stats", { method: "GET" });
|
|
||||||
expect(msg?.content).toContain("3");
|
|
||||||
expect(msg?.content).toContain("42");
|
|
||||||
expect(msg?.content).toContain("Pending");
|
|
||||||
expect(msg?.content).toContain("Completed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/queue is an alias for /jobs", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ pending: 0, active: 0 }));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/queue");
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/stats", { method: "GET" });
|
|
||||||
expect(msg?.role).toBe("assistant");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows paused indicator when queue is paused", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ pending: 0, active: 0, paused: true }));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/jobs");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("paused");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/pause", () => {
|
|
||||||
it("calls POST /api/orchestrator/queue/pause", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
|
||||||
makeOkResponse({ success: true, message: "Queue paused." })
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/pause");
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/pause", {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
expect(msg?.content).toContain("paused");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles API error response", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(makeOkResponse({ error: "Already paused." }));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/pause");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("failed");
|
|
||||||
expect(msg?.content).toContain("Already paused");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles network error", async () => {
|
|
||||||
mockFetch.mockRejectedValueOnce(new Error("Network failure"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/pause");
|
|
||||||
|
|
||||||
expect(msg?.content).toContain("Error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/resume", () => {
|
|
||||||
it("calls POST /api/orchestrator/queue/resume", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
|
||||||
makeOkResponse({ success: true, message: "Queue resumed." })
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/resume");
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/resume", {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
expect(msg?.content).toContain("resumed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unknown command", () => {
|
|
||||||
it("returns help hint for unknown commands", async () => {
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "/unknown-command");
|
|
||||||
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
expect(msg?.content).toContain("Unknown command");
|
|
||||||
expect(msg?.content).toContain("/unknown-command");
|
|
||||||
expect(msg?.content).toContain("/help");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("non-command input", () => {
|
|
||||||
it("returns null for regular messages", async () => {
|
|
||||||
const { result } = renderHook(() => useOrchestratorCommands());
|
|
||||||
const msg = await runCommand(result.current.executeCommand, "hello world");
|
|
||||||
|
|
||||||
expect(msg).toBeNull();
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
/**
|
|
||||||
* useOrchestratorCommands hook
|
|
||||||
*
|
|
||||||
* Parses chat messages for `/command` prefixes and routes them to the
|
|
||||||
* orchestrator proxy API routes instead of the LLM.
|
|
||||||
*
|
|
||||||
* Supported commands:
|
|
||||||
* /status — GET /api/orchestrator/health
|
|
||||||
* /agents — GET /api/orchestrator/agents
|
|
||||||
* /jobs — GET /api/orchestrator/queue/stats
|
|
||||||
* /queue — alias for /jobs
|
|
||||||
* /pause — POST /api/orchestrator/queue/pause
|
|
||||||
* /resume — POST /api/orchestrator/queue/resume
|
|
||||||
* /help — Display available commands locally (no API call)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import type { Message } from "@/hooks/useChat";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Command definitions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface OrchestratorCommand {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
aliases?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ORCHESTRATOR_COMMANDS: OrchestratorCommand[] = [
|
|
||||||
{ name: "/status", description: "Show orchestrator health and status" },
|
|
||||||
{ name: "/agents", description: "List all running agents" },
|
|
||||||
{ name: "/jobs", description: "Show queue statistics", aliases: ["/queue"] },
|
|
||||||
{ name: "/pause", description: "Pause the job queue" },
|
|
||||||
{ name: "/resume", description: "Resume the job queue" },
|
|
||||||
{ name: "/help", description: "Show available commands" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API response shapes (loosely typed — orchestrator may vary)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface HealthResponse {
|
|
||||||
status?: string;
|
|
||||||
version?: string;
|
|
||||||
uptime?: number;
|
|
||||||
ready?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Agent {
|
|
||||||
id?: string;
|
|
||||||
sessionKey?: string;
|
|
||||||
status?: string;
|
|
||||||
type?: string;
|
|
||||||
agentStatus?: string;
|
|
||||||
startedAt?: string;
|
|
||||||
label?: string;
|
|
||||||
channel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentsResponse {
|
|
||||||
agents?: Agent[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QueueStats {
|
|
||||||
pending?: number;
|
|
||||||
active?: number;
|
|
||||||
completed?: number;
|
|
||||||
failed?: number;
|
|
||||||
waiting?: number;
|
|
||||||
delayed?: number;
|
|
||||||
paused?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionResponse {
|
|
||||||
success?: boolean;
|
|
||||||
message?: string;
|
|
||||||
status?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeId(): string {
|
|
||||||
return `orch-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMessage(content: string): Message {
|
|
||||||
return {
|
|
||||||
id: makeId(),
|
|
||||||
role: "assistant",
|
|
||||||
content,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(command: string, detail: string): Message {
|
|
||||||
return makeMessage(
|
|
||||||
`**Error running \`${command}\`**\n\n${detail}\n\n_Check that the orchestrator is running and the API key is configured._`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Formatters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function formatStatus(data: HealthResponse): string {
|
|
||||||
if (data.error) {
|
|
||||||
return `**Orchestrator Status**\n\nStatus: Not reachable\n\nError: ${data.error}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabel = data.status ?? (data.ready === true ? "ready" : "unknown");
|
|
||||||
const isReady =
|
|
||||||
statusLabel === "ready" ||
|
|
||||||
statusLabel === "ok" ||
|
|
||||||
statusLabel === "healthy" ||
|
|
||||||
data.ready === true;
|
|
||||||
const badge = isReady ? "Ready" : "Not Ready";
|
|
||||||
|
|
||||||
const lines: string[] = [
|
|
||||||
`**Orchestrator Status**\n`,
|
|
||||||
`| Field | Value |`,
|
|
||||||
`|---|---|`,
|
|
||||||
`| Status | **${badge}** |`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (data.version != null) {
|
|
||||||
lines.push(`| Version | \`${data.version}\` |`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.uptime != null) {
|
|
||||||
const uptimeSec = Math.floor(data.uptime);
|
|
||||||
const hours = Math.floor(uptimeSec / 3600);
|
|
||||||
const mins = Math.floor((uptimeSec % 3600) / 60);
|
|
||||||
const secs = uptimeSec % 60;
|
|
||||||
const uptimeStr =
|
|
||||||
hours > 0
|
|
||||||
? `${String(hours)}h ${String(mins)}m ${String(secs)}s`
|
|
||||||
: `${String(mins)}m ${String(secs)}s`;
|
|
||||||
lines.push(`| Uptime | ${uptimeStr} |`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAgents(raw: unknown): string {
|
|
||||||
let agents: Agent[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(raw)) {
|
|
||||||
agents = raw as Agent[];
|
|
||||||
} else if (raw !== null && typeof raw === "object") {
|
|
||||||
const obj = raw as AgentsResponse;
|
|
||||||
if (obj.error) {
|
|
||||||
return `**Agents**\n\nError: ${obj.error}`;
|
|
||||||
}
|
|
||||||
if (Array.isArray(obj.agents)) {
|
|
||||||
agents = obj.agents;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agents.length === 0) {
|
|
||||||
return "**Agents**\n\nNo agents currently running.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [
|
|
||||||
`**Agents** (${String(agents.length)} total)\n`,
|
|
||||||
`| ID / Key | Status | Type / Channel | Started |`,
|
|
||||||
`|---|---|---|---|`,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const agent of agents) {
|
|
||||||
const id = agent.id ?? agent.sessionKey ?? "—";
|
|
||||||
const status = agent.agentStatus ?? agent.status ?? "—";
|
|
||||||
const type = agent.type ?? agent.channel ?? "—";
|
|
||||||
const started = agent.startedAt ? new Date(agent.startedAt).toLocaleString() : "—";
|
|
||||||
lines.push(`| \`${id}\` | ${status} | ${type} | ${started} |`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatQueueStats(data: QueueStats): string {
|
|
||||||
if (data.error) {
|
|
||||||
return `**Queue Stats**\n\nError: ${data.error}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [`**Queue Statistics**\n`, `| Metric | Count |`, `|---|---|`];
|
|
||||||
|
|
||||||
const metrics: [string, number | undefined][] = [
|
|
||||||
["Pending", data.pending ?? data.waiting],
|
|
||||||
["Active", data.active],
|
|
||||||
["Delayed", data.delayed],
|
|
||||||
["Completed", data.completed],
|
|
||||||
["Failed", data.failed],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [label, value] of metrics) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
lines.push(`| ${label} | ${String(value)} |`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.paused === true) {
|
|
||||||
lines.push("\n_Queue is currently **paused**._");
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAction(command: string, data: ActionResponse): string {
|
|
||||||
if (data.error) {
|
|
||||||
return `**${command}** failed.\n\nError: ${data.error}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const verb = command === "/pause" ? "paused" : "resumed";
|
|
||||||
const msg = data.message ?? data.status ?? `Queue ${verb} successfully.`;
|
|
||||||
return `**Queue ${verb}**\n\n${msg}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHelp(): string {
|
|
||||||
const lines: string[] = [
|
|
||||||
"**Available Orchestrator Commands**\n",
|
|
||||||
"| Command | Description |",
|
|
||||||
"|---|---|",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const cmd of ORCHESTRATOR_COMMANDS) {
|
|
||||||
const name = cmd.aliases ? `${cmd.name} (${cmd.aliases.join(", ")})` : cmd.name;
|
|
||||||
lines.push(`| \`${name}\` | ${cmd.description} |`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("\n_Commands starting with `/` are routed to the orchestrator instead of the LLM._");
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Command parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function parseCommandName(content: string): string | null {
|
|
||||||
const trimmed = content.trim();
|
|
||||||
if (!trimmed.startsWith("/")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
return parts[0]?.toLowerCase() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hook
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface UseOrchestratorCommandsReturn {
|
|
||||||
/**
|
|
||||||
* Returns true if the content looks like an orchestrator command.
|
|
||||||
*/
|
|
||||||
isCommand: (content: string) => boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute an orchestrator command.
|
|
||||||
* Returns a Message with formatted markdown output, or null if not a command.
|
|
||||||
*/
|
|
||||||
executeCommand: (content: string) => Promise<Message | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOrchestratorCommands(): UseOrchestratorCommandsReturn {
|
|
||||||
const isCommand = useCallback((content: string): boolean => {
|
|
||||||
return content.trim().startsWith("/");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const executeCommand = useCallback(async (content: string): Promise<Message | null> => {
|
|
||||||
const command = parseCommandName(content);
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /help — local, no network
|
|
||||||
if (command === "/help") {
|
|
||||||
return makeMessage(formatHelp());
|
|
||||||
}
|
|
||||||
|
|
||||||
// /status
|
|
||||||
if (command === "/status") {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/orchestrator/health", { method: "GET" });
|
|
||||||
const data = (await res.json()) as HealthResponse;
|
|
||||||
return makeMessage(formatStatus(data));
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err instanceof Error ? err.message : "Network error";
|
|
||||||
return errorMessage("/status", detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /agents
|
|
||||||
if (command === "/agents") {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/orchestrator/agents", { method: "GET" });
|
|
||||||
const data: unknown = await res.json();
|
|
||||||
return makeMessage(formatAgents(data));
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err instanceof Error ? err.message : "Network error";
|
|
||||||
return errorMessage("/agents", detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /jobs or /queue
|
|
||||||
if (command === "/jobs" || command === "/queue") {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/orchestrator/queue/stats", { method: "GET" });
|
|
||||||
const data = (await res.json()) as QueueStats;
|
|
||||||
return makeMessage(formatQueueStats(data));
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err instanceof Error ? err.message : "Network error";
|
|
||||||
return errorMessage(command, detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /pause
|
|
||||||
if (command === "/pause") {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/orchestrator/queue/pause", { method: "POST" });
|
|
||||||
const data = (await res.json()) as ActionResponse;
|
|
||||||
return makeMessage(formatAction("/pause", data));
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err instanceof Error ? err.message : "Network error";
|
|
||||||
return errorMessage("/pause", detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /resume
|
|
||||||
if (command === "/resume") {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/orchestrator/queue/resume", { method: "POST" });
|
|
||||||
const data = (await res.json()) as ActionResponse;
|
|
||||||
return makeMessage(formatAction("/resume", data));
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err instanceof Error ? err.message : "Network error";
|
|
||||||
return errorMessage("/resume", detail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown command — show help hint
|
|
||||||
return makeMessage(
|
|
||||||
`Unknown command: \`${command}\`\n\nType \`/help\` to see available commands.`
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { isCommand, executeCommand };
|
|
||||||
}
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file useTerminal.test.ts
|
|
||||||
* @description Unit tests for the useTerminal hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
||||||
import { useTerminal } from "./useTerminal";
|
|
||||||
import type { Socket } from "socket.io-client";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mock socket.io-client
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
vi.mock("socket.io-client");
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mock lib/config
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
vi.mock("@/lib/config", () => ({
|
|
||||||
API_BASE_URL: "http://localhost:3001",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface MockSocket {
|
|
||||||
on: ReturnType<typeof vi.fn>;
|
|
||||||
off: ReturnType<typeof vi.fn>;
|
|
||||||
emit: ReturnType<typeof vi.fn>;
|
|
||||||
disconnect: ReturnType<typeof vi.fn>;
|
|
||||||
connected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useTerminal", () => {
|
|
||||||
let mockSocket: MockSocket;
|
|
||||||
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
|
||||||
let mockIo: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
socketEventHandlers = {};
|
|
||||||
|
|
||||||
mockSocket = {
|
|
||||||
on: vi.fn((event: string, handler: (data: unknown) => void) => {
|
|
||||||
socketEventHandlers[event] = handler;
|
|
||||||
return mockSocket;
|
|
||||||
}),
|
|
||||||
off: vi.fn(),
|
|
||||||
emit: vi.fn(),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
connected: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const socketIo = await import("socket.io-client");
|
|
||||||
mockIo = vi.mocked(socketIo.io);
|
|
||||||
mockIo.mockReturnValue(mockSocket as unknown as Socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Connection
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("connection lifecycle", () => {
|
|
||||||
it("should connect to the /terminal namespace with auth token", () => {
|
|
||||||
renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockIo).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("/terminal"),
|
|
||||||
expect.objectContaining({
|
|
||||||
auth: { token: "test-token" },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should start disconnected and update when connected event fires", async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isConnected).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update sessionId when terminal:created event fires", async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
name: "main",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessionId).toBe("session-abc");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clear sessionId when disconnect event fires", async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
name: "main",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessionId).toBe("session-abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.disconnect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
expect(result.current.sessionId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set connectionError when connect_error fires", async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect_error?.(new Error("Connection refused"));
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.connectionError).toBe("Connection refused");
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not connect when token is empty", () => {
|
|
||||||
renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockIo).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Output and exit callbacks
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("event callbacks", () => {
|
|
||||||
it("should call onOutput when terminal:output fires", () => {
|
|
||||||
const onOutput = vi.fn();
|
|
||||||
|
|
||||||
renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
onOutput,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:output"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
data: "hello world\r\n",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onOutput).toHaveBeenCalledWith("session-abc", "hello world\r\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onExit when terminal:exit fires and clear sessionId", async () => {
|
|
||||||
const onExit = vi.fn();
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
onExit,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
name: "main",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:exit"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
exitCode: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(onExit).toHaveBeenCalledWith({ sessionId: "session-abc", exitCode: 0 });
|
|
||||||
expect(result.current.sessionId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onError when terminal:error fires", () => {
|
|
||||||
const onError = vi.fn();
|
|
||||||
|
|
||||||
renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
onError,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:error"]?.({
|
|
||||||
message: "PTY spawn failed",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onError).toHaveBeenCalledWith("PTY spawn failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Control functions
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("createSession", () => {
|
|
||||||
it("should emit terminal:create with options when connected", () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.createSession({ cols: 120, rows: 40, name: "test" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", {
|
|
||||||
cols: 120,
|
|
||||||
rows: 40,
|
|
||||||
name: "test",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not emit terminal:create when disconnected", () => {
|
|
||||||
mockSocket.connected = false;
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.createSession({ cols: 80, rows: 24 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sendInput", () => {
|
|
||||||
it("should emit terminal:input with sessionId and data", () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
name: "main",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.sendInput("ls -la\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", {
|
|
||||||
sessionId: "session-abc",
|
|
||||||
data: "ls -la\n",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not emit when no sessionId is set", () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.sendInput("ls -la\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resize", () => {
|
|
||||||
it("should emit terminal:resize with sessionId, cols, and rows", () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
name: "main",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.resize(100, 30);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", {
|
|
||||||
sessionId: "session-abc",
|
|
||||||
cols: 100,
|
|
||||||
rows: 30,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("closeSession", () => {
|
|
||||||
it("should emit terminal:close and clear sessionId", async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
name: "main",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessionId).toBe("session-abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.closeSession();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
|
||||||
sessionId: "session-abc",
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessionId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Cleanup
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("cleanup", () => {
|
|
||||||
it("should disconnect socket on unmount", () => {
|
|
||||||
const { unmount } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:close for active session on unmount", () => {
|
|
||||||
const { result, unmount } = renderHook(() =>
|
|
||||||
useTerminal({
|
|
||||||
token: "test-token",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-abc",
|
|
||||||
name: "main",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.sessionId).toBe("session-abc");
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
|
||||||
sessionId: "session-abc",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
/**
|
|
||||||
* useTerminal hook
|
|
||||||
*
|
|
||||||
* Manages a WebSocket connection to the /terminal namespace and a PTY terminal session.
|
|
||||||
* Follows the same patterns as useVoiceInput and useWebSocket.
|
|
||||||
*
|
|
||||||
* Protocol (from terminal.gateway.ts):
|
|
||||||
* 1. Connect with auth token in handshake
|
|
||||||
* 2. Emit terminal:create → receive terminal:created { sessionId, name, cols, rows }
|
|
||||||
* 3. Emit terminal:input { sessionId, data } to send keystrokes
|
|
||||||
* 4. Receive terminal:output { sessionId, data } for stdout/stderr
|
|
||||||
* 5. Emit terminal:resize { sessionId, cols, rows } on window resize
|
|
||||||
* 6. Emit terminal:close { sessionId } to terminate the PTY
|
|
||||||
* 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit
|
|
||||||
* 8. Receive terminal:error { message } on errors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
|
||||||
import type { Socket } from "socket.io-client";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import { API_BASE_URL } from "@/lib/config";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Types
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export interface CreateSessionOptions {
|
|
||||||
name?: string;
|
|
||||||
cols?: number;
|
|
||||||
rows?: number;
|
|
||||||
cwd?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalSession {
|
|
||||||
sessionId: string;
|
|
||||||
name: string;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalExitEvent {
|
|
||||||
sessionId: string;
|
|
||||||
exitCode: number;
|
|
||||||
signal?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseTerminalOptions {
|
|
||||||
/** Authentication token for WebSocket handshake */
|
|
||||||
token: string;
|
|
||||||
/** Callback fired when terminal output is received */
|
|
||||||
onOutput?: (sessionId: string, data: string) => void;
|
|
||||||
/** Callback fired when a terminal session exits */
|
|
||||||
onExit?: (event: TerminalExitEvent) => void;
|
|
||||||
/** Callback fired on terminal errors */
|
|
||||||
onError?: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseTerminalReturn {
|
|
||||||
/** Whether the WebSocket is connected */
|
|
||||||
isConnected: boolean;
|
|
||||||
/** The current terminal session ID, or null if no session is active */
|
|
||||||
sessionId: string | null;
|
|
||||||
/** Create a new PTY session */
|
|
||||||
createSession: (options?: CreateSessionOptions) => void;
|
|
||||||
/** Send input data to the terminal */
|
|
||||||
sendInput: (data: string) => void;
|
|
||||||
/** Resize the terminal PTY */
|
|
||||||
resize: (cols: number, rows: number) => void;
|
|
||||||
/** Close the current PTY session */
|
|
||||||
closeSession: () => void;
|
|
||||||
/** Connection error message, if any */
|
|
||||||
connectionError: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Payload shapes matching terminal.dto.ts
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface TerminalCreatedPayload {
|
|
||||||
sessionId: string;
|
|
||||||
name: string;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalOutputPayload {
|
|
||||||
sessionId: string;
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalExitPayload {
|
|
||||||
sessionId: string;
|
|
||||||
exitCode: number;
|
|
||||||
signal?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalErrorPayload {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Security validation
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function validateWebSocketSecurity(url: string): void {
|
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
|
||||||
const isSecure = url.startsWith("https://") || url.startsWith("wss://");
|
|
||||||
|
|
||||||
if (isProduction && !isSecure) {
|
|
||||||
console.warn(
|
|
||||||
"[Security Warning] Terminal WebSocket using insecure protocol (ws://). " +
|
|
||||||
"Authentication tokens may be exposed. Use wss:// in production."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Hook
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing a real PTY terminal session over WebSocket.
|
|
||||||
*
|
|
||||||
* @param options - Configuration including auth token and event callbacks
|
|
||||||
* @returns Terminal state and control functions
|
|
||||||
*/
|
|
||||||
export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
|
||||||
const { token, onOutput, onExit, onError } = options;
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Keep callbacks in refs to avoid stale closures without causing reconnects
|
|
||||||
const onOutputRef = useRef(onOutput);
|
|
||||||
const onExitRef = useRef(onExit);
|
|
||||||
const onErrorRef = useRef(onError);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onOutputRef.current = onOutput;
|
|
||||||
}, [onOutput]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onExitRef.current = onExit;
|
|
||||||
}, [onExit]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onErrorRef.current = onError;
|
|
||||||
}, [onError]);
|
|
||||||
|
|
||||||
// Connect to the /terminal namespace
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsUrl = API_BASE_URL;
|
|
||||||
validateWebSocketSecurity(wsUrl);
|
|
||||||
|
|
||||||
setConnectionError(null);
|
|
||||||
|
|
||||||
const socket = io(`${wsUrl}/terminal`, {
|
|
||||||
auth: { token },
|
|
||||||
transports: ["websocket", "polling"],
|
|
||||||
});
|
|
||||||
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
const handleConnect = (): void => {
|
|
||||||
setIsConnected(true);
|
|
||||||
setConnectionError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = (): void => {
|
|
||||||
setIsConnected(false);
|
|
||||||
setSessionId(null);
|
|
||||||
sessionIdRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConnectError = (error: Error): void => {
|
|
||||||
setConnectionError(error.message || "Terminal connection failed");
|
|
||||||
setIsConnected(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalCreated = (payload: TerminalCreatedPayload): void => {
|
|
||||||
setSessionId(payload.sessionId);
|
|
||||||
sessionIdRef.current = payload.sessionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalOutput = (payload: TerminalOutputPayload): void => {
|
|
||||||
onOutputRef.current?.(payload.sessionId, payload.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalExit = (payload: TerminalExitPayload): void => {
|
|
||||||
onExitRef.current?.(payload);
|
|
||||||
setSessionId(null);
|
|
||||||
sessionIdRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalError = (payload: TerminalErrorPayload): void => {
|
|
||||||
onErrorRef.current?.(payload.message);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("connect", handleConnect);
|
|
||||||
socket.on("disconnect", handleDisconnect);
|
|
||||||
socket.on("connect_error", handleConnectError);
|
|
||||||
socket.on("terminal:created", handleTerminalCreated);
|
|
||||||
socket.on("terminal:output", handleTerminalOutput);
|
|
||||||
socket.on("terminal:exit", handleTerminalExit);
|
|
||||||
socket.on("terminal:error", handleTerminalError);
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
socket.off("connect", handleConnect);
|
|
||||||
socket.off("disconnect", handleDisconnect);
|
|
||||||
socket.off("connect_error", handleConnectError);
|
|
||||||
socket.off("terminal:created", handleTerminalCreated);
|
|
||||||
socket.off("terminal:output", handleTerminalOutput);
|
|
||||||
socket.off("terminal:exit", handleTerminalExit);
|
|
||||||
socket.off("terminal:error", handleTerminalError);
|
|
||||||
|
|
||||||
// Close active session before disconnecting
|
|
||||||
const currentSessionId = sessionIdRef.current;
|
|
||||||
if (currentSessionId) {
|
|
||||||
socket.emit("terminal:close", { sessionId: currentSessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.disconnect();
|
|
||||||
socketRef.current = null;
|
|
||||||
};
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
if (!socket?.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {};
|
|
||||||
if (createOptions.name !== undefined) payload.name = createOptions.name;
|
|
||||||
if (createOptions.cols !== undefined) payload.cols = createOptions.cols;
|
|
||||||
if (createOptions.rows !== undefined) payload.rows = createOptions.rows;
|
|
||||||
if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd;
|
|
||||||
|
|
||||||
socket.emit("terminal:create", payload);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendInput = useCallback((data: string): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
const currentSessionId = sessionIdRef.current;
|
|
||||||
|
|
||||||
if (!socket?.connected || !currentSessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("terminal:input", { sessionId: currentSessionId, data });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resize = useCallback((cols: number, rows: number): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
const currentSessionId = sessionIdRef.current;
|
|
||||||
|
|
||||||
if (!socket?.connected || !currentSessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("terminal:resize", { sessionId: currentSessionId, cols, rows });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSession = useCallback((): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
const currentSessionId = sessionIdRef.current;
|
|
||||||
|
|
||||||
if (!socket?.connected || !currentSessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("terminal:close", { sessionId: currentSessionId });
|
|
||||||
setSessionId(null);
|
|
||||||
sessionIdRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isConnected,
|
|
||||||
sessionId,
|
|
||||||
createSession,
|
|
||||||
sendInput,
|
|
||||||
resize,
|
|
||||||
closeSession,
|
|
||||||
connectionError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,690 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file useTerminalSessions.test.ts
|
|
||||||
* @description Unit tests for the useTerminalSessions hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
||||||
import { useTerminalSessions } from "./useTerminalSessions";
|
|
||||||
import type { Socket } from "socket.io-client";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mock socket.io-client
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
vi.mock("socket.io-client");
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mock lib/config
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
vi.mock("@/lib/config", () => ({
|
|
||||||
API_BASE_URL: "http://localhost:3001",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface MockSocket {
|
|
||||||
on: ReturnType<typeof vi.fn>;
|
|
||||||
off: ReturnType<typeof vi.fn>;
|
|
||||||
emit: ReturnType<typeof vi.fn>;
|
|
||||||
disconnect: ReturnType<typeof vi.fn>;
|
|
||||||
connected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useTerminalSessions", () => {
|
|
||||||
let mockSocket: MockSocket;
|
|
||||||
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
|
||||||
let mockIo: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
socketEventHandlers = {};
|
|
||||||
|
|
||||||
mockSocket = {
|
|
||||||
on: vi.fn((event: string, handler: (data: unknown) => void) => {
|
|
||||||
socketEventHandlers[event] = handler;
|
|
||||||
return mockSocket;
|
|
||||||
}),
|
|
||||||
off: vi.fn(),
|
|
||||||
emit: vi.fn(),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
connected: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const socketIo = await import("socket.io-client");
|
|
||||||
mockIo = vi.mocked(socketIo.io);
|
|
||||||
mockIo.mockReturnValue(mockSocket as unknown as Socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Connection lifecycle
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("connection lifecycle", () => {
|
|
||||||
it("should connect to the /terminal namespace with auth token", () => {
|
|
||||||
renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
expect(mockIo).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("/terminal"),
|
|
||||||
expect.objectContaining({
|
|
||||||
auth: { token: "test-token" },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should start disconnected", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update isConnected when connect event fires", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isConnected).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set connectionError when connect_error fires", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect_error?.(new Error("Connection refused"));
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.connectionError).toBe("Connection refused");
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not connect when token is empty", () => {
|
|
||||||
renderHook(() => useTerminalSessions({ token: "" }));
|
|
||||||
|
|
||||||
expect(mockIo).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disconnect socket on unmount", () => {
|
|
||||||
const { unmount } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Session creation
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("createSession", () => {
|
|
||||||
it("should emit terminal:create when connected", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.createSession({ name: "bash", cols: 120, rows: 40 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", {
|
|
||||||
name: "bash",
|
|
||||||
cols: 120,
|
|
||||||
rows: 40,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not emit terminal:create when disconnected", () => {
|
|
||||||
mockSocket.connected = false;
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.createSession();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add session to sessions map when terminal:created fires", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
|
||||||
expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1");
|
|
||||||
expect(result.current.sessions.get("session-1")?.status).toBe("active");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set first created session as active", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.activeSessionId).toBe("session-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not change active session when a second session is created", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.activeSessionId).toBe("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-2",
|
|
||||||
name: "Terminal 2",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.size).toBe(2);
|
|
||||||
// Active session should remain session-1
|
|
||||||
expect(result.current.activeSessionId).toBe("session-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should manage multiple sessions in the sessions map", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-2",
|
|
||||||
name: "Terminal 2",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.size).toBe(2);
|
|
||||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
|
||||||
expect(result.current.sessions.has("session-2")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Session close
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("closeSession", () => {
|
|
||||||
it("should emit terminal:close and remove session from map", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.closeSession("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", {
|
|
||||||
sessionId: "session-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.has("session-1")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should switch active session to another when active is closed", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-2",
|
|
||||||
name: "Terminal 2",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.activeSessionId).toBe("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.closeSession("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Should switch to session-2
|
|
||||||
expect(result.current.activeSessionId).toBe("session-2");
|
|
||||||
expect(result.current.sessions.has("session-1")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set activeSessionId to null when last session is closed", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.activeSessionId).toBe("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.closeSession("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.size).toBe(0);
|
|
||||||
expect(result.current.activeSessionId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Rename session
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("renameSession", () => {
|
|
||||||
it("should update the session name in the sessions map", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.renameSession("session-1", "My Custom Shell");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.get("session-1")?.name).toBe("My Custom Shell");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect other session names", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-2",
|
|
||||||
name: "Terminal 2",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.renameSession("session-1", "Custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.get("session-1")?.name).toBe("Custom");
|
|
||||||
expect(result.current.sessions.get("session-2")?.name).toBe("Terminal 2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// setActiveSession
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("setActiveSession", () => {
|
|
||||||
it("should update activeSessionId", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-2",
|
|
||||||
name: "Terminal 2",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.activeSessionId).toBe("session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setActiveSession("session-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.activeSessionId).toBe("session-2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// sendInput
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("sendInput", () => {
|
|
||||||
it("should emit terminal:input with sessionId and data", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.sendInput("session-1", "ls -la\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", {
|
|
||||||
sessionId: "session-1",
|
|
||||||
data: "ls -la\n",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not emit when disconnected", () => {
|
|
||||||
mockSocket.connected = false;
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.sendInput("session-1", "ls\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// resize
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("resize", () => {
|
|
||||||
it("should emit terminal:resize with sessionId, cols, and rows", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.connect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.resize("session-1", 120, 40);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", {
|
|
||||||
sessionId: "session-1",
|
|
||||||
cols: 120,
|
|
||||||
rows: 40,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Output callback routing
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("registerOutputCallback", () => {
|
|
||||||
it("should call the registered callback when terminal:output fires for that session", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
const cb = vi.fn();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.registerOutputCallback("session-1", cb);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:output"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
data: "hello world\r\n",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(cb).toHaveBeenCalledWith("hello world\r\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not call callback for a different session", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
const cbSession1 = vi.fn();
|
|
||||||
const cbSession2 = vi.fn();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.registerOutputCallback("session-1", cbSession1);
|
|
||||||
result.current.registerOutputCallback("session-2", cbSession2);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:output"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
data: "output for session 1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(cbSession1).toHaveBeenCalledWith("output for session 1");
|
|
||||||
expect(cbSession2).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should stop calling callback after unsubscribing", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
const cb = vi.fn();
|
|
||||||
let unsubscribe: (() => void) | undefined;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
unsubscribe = result.current.registerOutputCallback("session-1", cb);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
unsubscribe?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:output"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
data: "should not arrive",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(cb).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support multiple callbacks for the same session", () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
const cb1 = vi.fn();
|
|
||||||
const cb2 = vi.fn();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.registerOutputCallback("session-1", cb1);
|
|
||||||
result.current.registerOutputCallback("session-1", cb2);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:output"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
data: "broadcast",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(cb1).toHaveBeenCalledWith("broadcast");
|
|
||||||
expect(cb2).toHaveBeenCalledWith("broadcast");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Exit event
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("terminal:exit handling", () => {
|
|
||||||
it("should mark session as exited when terminal:exit fires", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.get("session-1")?.status).toBe("active");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:exit"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
exitCode: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.get("session-1")?.status).toBe("exited");
|
|
||||||
expect(result.current.sessions.get("session-1")?.exitCode).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not remove the session from the map on exit", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:exit"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
exitCode: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Session remains in map — user can restart or close it manually
|
|
||||||
expect(result.current.sessions.has("session-1")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Disconnect handling
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("disconnect handling", () => {
|
|
||||||
it("should mark all active sessions as exited on disconnect", async () => {
|
|
||||||
const { result } = renderHook(() => useTerminalSessions({ token: "test-token" }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-1",
|
|
||||||
name: "Terminal 1",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
socketEventHandlers["terminal:created"]?.({
|
|
||||||
sessionId: "session-2",
|
|
||||||
name: "Terminal 2",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.sessions.size).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
socketEventHandlers.disconnect?.(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isConnected).toBe(false);
|
|
||||||
expect(result.current.sessions.get("session-1")?.status).toBe("exited");
|
|
||||||
expect(result.current.sessions.get("session-2")?.status).toBe("exited");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
/**
|
|
||||||
* useTerminalSessions hook
|
|
||||||
*
|
|
||||||
* Manages multiple PTY terminal sessions over a single WebSocket connection
|
|
||||||
* to the /terminal namespace. Supports creating, closing, renaming, and switching
|
|
||||||
* between sessions, with per-session output callback multiplexing.
|
|
||||||
*
|
|
||||||
* Protocol (from terminal.gateway.ts):
|
|
||||||
* 1. Connect with auth token in handshake
|
|
||||||
* 2. Emit terminal:create { name?, cols?, rows? } → receive terminal:created { sessionId, name, cols, rows }
|
|
||||||
* 3. Emit terminal:input { sessionId, data } to send keystrokes
|
|
||||||
* 4. Receive terminal:output { sessionId, data } for stdout/stderr
|
|
||||||
* 5. Emit terminal:resize { sessionId, cols, rows } on window resize
|
|
||||||
* 6. Emit terminal:close { sessionId } to terminate the PTY
|
|
||||||
* 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit
|
|
||||||
* 8. Receive terminal:error { message } on errors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
|
||||||
import type { Socket } from "socket.io-client";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import { API_BASE_URL } from "@/lib/config";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Types
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export type SessionStatus = "active" | "exited";
|
|
||||||
|
|
||||||
export interface SessionInfo {
|
|
||||||
/** Session identifier returned by the server */
|
|
||||||
sessionId: string;
|
|
||||||
/** Human-readable tab label */
|
|
||||||
name: string;
|
|
||||||
/** Whether the PTY process is still running */
|
|
||||||
status: SessionStatus;
|
|
||||||
/** Exit code, populated when status === 'exited' */
|
|
||||||
exitCode?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateSessionOptions {
|
|
||||||
/** Optional label for the new session */
|
|
||||||
name?: string;
|
|
||||||
/** Terminal columns */
|
|
||||||
cols?: number;
|
|
||||||
/** Terminal rows */
|
|
||||||
rows?: number;
|
|
||||||
/** Working directory */
|
|
||||||
cwd?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseTerminalSessionsOptions {
|
|
||||||
/** Authentication token for WebSocket handshake */
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseTerminalSessionsReturn {
|
|
||||||
/** Map of sessionId → SessionInfo */
|
|
||||||
sessions: Map<string, SessionInfo>;
|
|
||||||
/** Currently active (visible) session id, or null if none */
|
|
||||||
activeSessionId: string | null;
|
|
||||||
/** Whether the WebSocket is connected */
|
|
||||||
isConnected: boolean;
|
|
||||||
/** Connection error message, if any */
|
|
||||||
connectionError: string | null;
|
|
||||||
/** Create a new PTY session */
|
|
||||||
createSession: (options?: CreateSessionOptions) => void;
|
|
||||||
/** Close an existing PTY session */
|
|
||||||
closeSession: (sessionId: string) => void;
|
|
||||||
/** Rename a session (local label only, not persisted to server) */
|
|
||||||
renameSession: (sessionId: string, name: string) => void;
|
|
||||||
/** Switch the visible session */
|
|
||||||
setActiveSession: (sessionId: string) => void;
|
|
||||||
/** Send keyboard input to a session */
|
|
||||||
sendInput: (sessionId: string, data: string) => void;
|
|
||||||
/** Notify the server of a terminal resize */
|
|
||||||
resize: (sessionId: string, cols: number, rows: number) => void;
|
|
||||||
/**
|
|
||||||
* Register a callback that receives output data for a specific session.
|
|
||||||
* Returns an unsubscribe function — call it during cleanup.
|
|
||||||
*/
|
|
||||||
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Payload shapes matching terminal.dto.ts
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface TerminalCreatedPayload {
|
|
||||||
sessionId: string;
|
|
||||||
name: string;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalOutputPayload {
|
|
||||||
sessionId: string;
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalExitPayload {
|
|
||||||
sessionId: string;
|
|
||||||
exitCode: number;
|
|
||||||
signal?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalErrorPayload {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Security validation
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function validateWebSocketSecurity(url: string): void {
|
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
|
||||||
const isSecure = url.startsWith("https://") || url.startsWith("wss://");
|
|
||||||
|
|
||||||
if (isProduction && !isSecure) {
|
|
||||||
console.warn(
|
|
||||||
"[Security Warning] Terminal WebSocket using insecure protocol (ws://). " +
|
|
||||||
"Authentication tokens may be exposed. Use wss:// in production."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Hook
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing multiple PTY terminal sessions over a single WebSocket connection.
|
|
||||||
*
|
|
||||||
* @param options - Configuration including auth token
|
|
||||||
* @returns Multi-session terminal state and control functions
|
|
||||||
*/
|
|
||||||
export function useTerminalSessions(
|
|
||||||
options: UseTerminalSessionsOptions
|
|
||||||
): UseTerminalSessionsReturn {
|
|
||||||
const { token } = options;
|
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
|
||||||
// Per-session output callback registry; keyed by sessionId
|
|
||||||
const outputCallbacksRef = useRef<Map<string, Set<(data: string) => void>>>(new Map());
|
|
||||||
|
|
||||||
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map());
|
|
||||||
const [activeSessionId, setActiveSessionIdState] = useState<string | null>(null);
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Auto-select first available session when active becomes null
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeSessionId === null && sessions.size > 0) {
|
|
||||||
const firstId = sessions.keys().next().value;
|
|
||||||
if (firstId !== undefined) {
|
|
||||||
setActiveSessionIdState(firstId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeSessionId, sessions]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WebSocket connection
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsUrl = API_BASE_URL;
|
|
||||||
validateWebSocketSecurity(wsUrl);
|
|
||||||
|
|
||||||
setConnectionError(null);
|
|
||||||
|
|
||||||
const socket = io(`${wsUrl}/terminal`, {
|
|
||||||
auth: { token },
|
|
||||||
transports: ["websocket", "polling"],
|
|
||||||
});
|
|
||||||
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
const handleConnect = (): void => {
|
|
||||||
setIsConnected(true);
|
|
||||||
setConnectionError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = (): void => {
|
|
||||||
setIsConnected(false);
|
|
||||||
// Sessions remain in the Map but are no longer interactive
|
|
||||||
setSessions((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
for (const [id, info] of next) {
|
|
||||||
if (info.status === "active") {
|
|
||||||
next.set(id, { ...info, status: "exited" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConnectError = (error: Error): void => {
|
|
||||||
setConnectionError(error.message || "Terminal connection failed");
|
|
||||||
setIsConnected(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalCreated = (payload: TerminalCreatedPayload): void => {
|
|
||||||
setSessions((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(payload.sessionId, {
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
name: payload.name,
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// Set as active session if none is currently active
|
|
||||||
setActiveSessionIdState((prev) => prev ?? payload.sessionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalOutput = (payload: TerminalOutputPayload): void => {
|
|
||||||
const callbacks = outputCallbacksRef.current.get(payload.sessionId);
|
|
||||||
if (callbacks) {
|
|
||||||
for (const cb of callbacks) {
|
|
||||||
cb(payload.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalExit = (payload: TerminalExitPayload): void => {
|
|
||||||
setSessions((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
const session = next.get(payload.sessionId);
|
|
||||||
if (session) {
|
|
||||||
next.set(payload.sessionId, {
|
|
||||||
...session,
|
|
||||||
status: "exited",
|
|
||||||
exitCode: payload.exitCode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTerminalError = (payload: TerminalErrorPayload): void => {
|
|
||||||
console.error("[Terminal] Error:", payload.message);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("connect", handleConnect);
|
|
||||||
socket.on("disconnect", handleDisconnect);
|
|
||||||
socket.on("connect_error", handleConnectError);
|
|
||||||
socket.on("terminal:created", handleTerminalCreated);
|
|
||||||
socket.on("terminal:output", handleTerminalOutput);
|
|
||||||
socket.on("terminal:exit", handleTerminalExit);
|
|
||||||
socket.on("terminal:error", handleTerminalError);
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
socket.off("connect", handleConnect);
|
|
||||||
socket.off("disconnect", handleDisconnect);
|
|
||||||
socket.off("connect_error", handleConnectError);
|
|
||||||
socket.off("terminal:created", handleTerminalCreated);
|
|
||||||
socket.off("terminal:output", handleTerminalOutput);
|
|
||||||
socket.off("terminal:exit", handleTerminalExit);
|
|
||||||
socket.off("terminal:error", handleTerminalError);
|
|
||||||
|
|
||||||
// Close all active sessions before disconnecting
|
|
||||||
const currentSessions = sessions;
|
|
||||||
for (const [id, info] of currentSessions) {
|
|
||||||
if (info.status === "active") {
|
|
||||||
socket.emit("terminal:close", { sessionId: id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.disconnect();
|
|
||||||
socketRef.current = null;
|
|
||||||
};
|
|
||||||
// Intentional: token is the only dep that should trigger reconnection
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Control functions
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
if (!socket?.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {};
|
|
||||||
if (createOptions.name !== undefined) payload.name = createOptions.name;
|
|
||||||
if (createOptions.cols !== undefined) payload.cols = createOptions.cols;
|
|
||||||
if (createOptions.rows !== undefined) payload.rows = createOptions.rows;
|
|
||||||
if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd;
|
|
||||||
|
|
||||||
socket.emit("terminal:create", payload);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeSession = useCallback((sessionId: string): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
if (socket?.connected) {
|
|
||||||
socket.emit("terminal:close", { sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessions((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(sessionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If closing the active session, activeSessionId becomes null
|
|
||||||
// and the auto-select useEffect will pick the first remaining session
|
|
||||||
setActiveSessionIdState((prev) => (prev === sessionId ? null : prev));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renameSession = useCallback((sessionId: string, name: string): void => {
|
|
||||||
setSessions((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
const session = next.get(sessionId);
|
|
||||||
if (session) {
|
|
||||||
next.set(sessionId, { ...session, name });
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setActiveSession = useCallback((sessionId: string): void => {
|
|
||||||
setActiveSessionIdState(sessionId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendInput = useCallback((sessionId: string, data: string): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
if (!socket?.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
socket.emit("terminal:input", { sessionId, data });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resize = useCallback((sessionId: string, cols: number, rows: number): void => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
if (!socket?.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
socket.emit("terminal:resize", { sessionId, cols, rows });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const registerOutputCallback = useCallback(
|
|
||||||
(sessionId: string, cb: (data: string) => void): (() => void) => {
|
|
||||||
const registry = outputCallbacksRef.current;
|
|
||||||
if (!registry.has(sessionId)) {
|
|
||||||
registry.set(sessionId, new Set());
|
|
||||||
}
|
|
||||||
// Safe: we just ensured the key exists
|
|
||||||
const callbackSet = registry.get(sessionId);
|
|
||||||
if (callbackSet) {
|
|
||||||
callbackSet.add(cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
registry.get(sessionId)?.delete(cb);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessions,
|
|
||||||
activeSessionId,
|
|
||||||
isConnected,
|
|
||||||
connectionError,
|
|
||||||
createSession,
|
|
||||||
closeSession,
|
|
||||||
renameSession,
|
|
||||||
setActiveSession,
|
|
||||||
sendInput,
|
|
||||||
resize,
|
|
||||||
registerOutputCallback,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,25 +7,25 @@
|
|||||||
|
|
||||||
**ID:** ms19-chat-terminal-20260225
|
**ID:** ms19-chat-terminal-20260225
|
||||||
**Statement:** Implement MS19 (Chat & Terminal System) — real terminal with PTY backend, chat streaming, master chat polish, project-level orchestrator chat, and agent output integration
|
**Statement:** Implement MS19 (Chat & Terminal System) — real terminal with PTY backend, chat streaming, master chat polish, project-level orchestrator chat, and agent output integration
|
||||||
**Phase:** Completion
|
**Phase:** Planning
|
||||||
**Current Milestone:** MS19-ChatTerminal
|
**Current Milestone:** MS19-ChatTerminal
|
||||||
**Progress:** 1 / 1 milestones
|
**Progress:** 0 / 1 milestones
|
||||||
**Status:** completed
|
**Status:** planning
|
||||||
**Last Updated:** 2026-02-26T04:20Z
|
**Last Updated:** 2026-02-25T20:00Z
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
1. Terminal panel has real xterm.js with PTY backend via WebSocket — **DONE** (PR #518)
|
1. Terminal panel has real xterm.js with PTY backend via WebSocket
|
||||||
2. Terminal supports multiple named sessions (create/close/rename tabs) — **DONE** (PR #520)
|
2. Terminal supports multiple named sessions (create/close/rename tabs)
|
||||||
3. Terminal sessions persist in PostgreSQL and recover on reconnect — **DONE** (PR #517)
|
3. Terminal sessions persist in PostgreSQL and recover on reconnect
|
||||||
4. Chat streaming renders tokens in real-time via SSE — **DONE** (PR #516)
|
4. Chat streaming renders tokens in real-time via SSE
|
||||||
5. Master chat sidebar accessible from any page (Cmd+Shift+J / Cmd+K) — **DONE** (PR #519)
|
5. Master chat sidebar accessible from any page (Cmd+Shift+J / Cmd+K)
|
||||||
6. Master chat supports model selection, temperature, conversation management — **DONE** (PR #519)
|
6. Master chat supports model selection, temperature, conversation management
|
||||||
7. Project-level chat can trigger orchestrator actions (/spawn, /status, /jobs) — **DONE** (PR #521)
|
7. Project-level chat can trigger orchestrator actions (/spawn, /status, /jobs)
|
||||||
8. Agent output from orchestrator viewable in terminal tabs — **DONE** (PR #522)
|
8. Agent output from orchestrator viewable in terminal tabs
|
||||||
9. All features support all 5 themes (Dark, Light, Nord, Dracula, Solarized) — **DONE** (CSS variables)
|
9. All features support all 5 themes (Dark, Light, Nord, Dracula, Solarized)
|
||||||
10. Lint, typecheck, and tests pass — **DONE** (1441 web + 3303 API = 4744 tests)
|
10. Lint, typecheck, and tests pass
|
||||||
11. Deployed and smoke-tested at mosaic.woltje.com — **DONE** (CI #635 green, web image sha:7165e7a deployed)
|
11. Deployed and smoke-tested at mosaic.woltje.com
|
||||||
|
|
||||||
## Existing Infrastructure
|
## Existing Infrastructure
|
||||||
|
|
||||||
@@ -43,9 +43,9 @@ Key components already built that MS19 builds upon:
|
|||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
| --- | ---- | ---------------------- | --------- | ------------------------- | ------------------------ | ---------- | ---------- |
|
| --- | ---- | ---------------------- | -------- | ------------------------- | ------------------------ | ---------- | --------- |
|
||||||
| 1 | MS19 | Chat & Terminal System | completed | per-task feature branches | #508,#509,#510,#511,#512 | 2026-02-25 | 2026-02-25 |
|
| 1 | MS19 | Chat & Terminal System | planning | per-task feature branches | #508,#509,#510,#511,#512 | 2026-02-25 | — |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -58,30 +58,14 @@ Key components already built that MS19 builds upon:
|
|||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| ------ | ----------------- |
|
| ------ | ----------------- |
|
||||||
| Budget | ~300K (estimated) |
|
| Budget | ~300K (estimated) |
|
||||||
| Used | ~220K |
|
| Used | ~0K |
|
||||||
| Mode | normal |
|
| Mode | normal |
|
||||||
|
|
||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||||
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------------------------------------- |
|
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------- |
|
||||||
| S1 | Claude Opus 4.6 | 2026-02-25T20:00Z | ~1h | context | Planning (PLAN-001) |
|
| S1 | Claude Opus 4.6 | 2026-02-25T20:00Z | — | — | Planning (PLAN-001) |
|
||||||
| S2 | Claude Opus 4.6 | 2026-02-25T21:00Z | ~2h | context | Wave 1+2 (5 tasks, PRs #515-518) |
|
|
||||||
| S3 | Claude Opus 4.6 | 2026-02-25T23:00Z | ~1.5h | context | Wave 3+4 (TERM-004, CHAT-002, ORCH-001, ORCH-002) |
|
|
||||||
| S4 | Claude Opus 4.6 | 2026-02-26T04:00Z | ~30m | completed | VER-001, DOC-001, VER-002 — mission complete |
|
|
||||||
|
|
||||||
## PRs Merged
|
|
||||||
|
|
||||||
| PR | Commit | Task | Description |
|
|
||||||
| ---- | ------- | -------- | ---------------------------------------- |
|
|
||||||
| #515 | 6290fc3 | TERM-001 | Terminal WebSocket gateway & PTY service |
|
|
||||||
| #516 | 7de0e73 | CHAT-001 | SSE chat streaming |
|
|
||||||
| #517 | 8128eb7 | TERM-002 | Terminal session persistence |
|
|
||||||
| #518 | 417c6ab | TERM-003 | xterm.js integration |
|
|
||||||
| #519 | 13aa52a | CHAT-002 | Master chat polish |
|
|
||||||
| #520 | 859dcfc | TERM-004 | Terminal tab management |
|
|
||||||
| #521 | b110c46 | ORCH-001 | Orchestrator command system |
|
|
||||||
| #522 | 9b2520c | ORCH-002 | Agent output terminal tabs |
|
|
||||||
|
|
||||||
## Scratchpad
|
## Scratchpad
|
||||||
|
|
||||||
|
|||||||
51
docs/PRD.md
51
docs/PRD.md
@@ -86,22 +86,6 @@ Theme package system, widget registry, WYSIWYG editor, Kanban filtering. PRs #49
|
|||||||
- Kanban board filtering by project, assignee, priority, search with URL persistence
|
- Kanban board filtering by project, assignee, priority, search with URL persistence
|
||||||
- 1,195 web tests, 3,243 API tests passing
|
- 1,195 web tests, 3,243 API tests passing
|
||||||
|
|
||||||
### MS19-ChatTerminal (v0.1.3) — In Progress
|
|
||||||
|
|
||||||
Real terminal with PTY backend, chat streaming, orchestrator integration. PRs #515-522. Issues #508-512.
|
|
||||||
|
|
||||||
- NestJS WebSocket gateway (/terminal namespace) with node-pty for real shell sessions
|
|
||||||
- Terminal session persistence in PostgreSQL (Prisma model: TerminalSession)
|
|
||||||
- xterm.js integration with FitAddon, WebLinksAddon, CSS variable theme support
|
|
||||||
- Multi-session terminal tabs: create/close/rename, tab switching, session recovery
|
|
||||||
- SSE chat streaming with token-by-token rendering, abort/cancel support
|
|
||||||
- Master chat polish: model selector dropdown, temperature/maxTokens config, ChatEmptyState
|
|
||||||
- Orchestrator command system: /status, /agents, /jobs, /pause, /resume, /help
|
|
||||||
- Agent output terminal: SSE streaming from orchestrator, lifecycle indicators, read-only view
|
|
||||||
- Command autocomplete with keyboard navigation in chat input
|
|
||||||
- 328 MS19-specific tests (268 web + 60 API), 4744 total passing
|
|
||||||
- Pending: deployment and smoke testing
|
|
||||||
|
|
||||||
### Bugfix: API Global Prefix (post-MS18) — Complete
|
### Bugfix: API Global Prefix (post-MS18) — Complete
|
||||||
|
|
||||||
PR #507. Fixed systemic 404 on all data endpoints.
|
PR #507. Fixed systemic 404 on all data endpoints.
|
||||||
@@ -306,7 +290,7 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
- UserPreference.theme persists selection across sessions
|
- UserPreference.theme persists selection across sessions
|
||||||
- **Status: COMPLETE (MS18) — PRs #493-495**
|
- **Status: COMPLETE (MS18) — PRs #493-495**
|
||||||
|
|
||||||
### FR-017: Terminal Panel (MS19) — COMPLETE
|
### FR-017: Terminal Panel (MS19)
|
||||||
|
|
||||||
- Bottom drawer panel, toggleable from header and sidebar
|
- Bottom drawer panel, toggleable from header and sidebar
|
||||||
- Real xterm.js terminal with PTY backend via WebSocket
|
- Real xterm.js terminal with PTY backend via WebSocket
|
||||||
@@ -315,24 +299,21 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
- Smart terminal operating at project/orchestrator level
|
- Smart terminal operating at project/orchestrator level
|
||||||
- ASSUMPTION: Terminal backend uses node-pty for PTY management, communicating via WebSocket namespace (/terminal). Rationale: node-pty is the standard for Node.js terminal emulation, used by VS Code.
|
- ASSUMPTION: Terminal backend uses node-pty for PTY management, communicating via WebSocket namespace (/terminal). Rationale: node-pty is the standard for Node.js terminal emulation, used by VS Code.
|
||||||
- ASSUMPTION: Terminal sessions are workspace-scoped and stored in PostgreSQL for recovery. Rationale: Consistent with existing workspace isolation pattern.
|
- ASSUMPTION: Terminal sessions are workspace-scoped and stored in PostgreSQL for recovery. Rationale: Consistent with existing workspace isolation pattern.
|
||||||
- **Status: COMPLETE (MS19) — PRs #515 (gateway), #517 (persistence), #518 (xterm.js), #520 (tabs), #522 (agent tabs). 60 API + 176 web tests.**
|
|
||||||
|
|
||||||
### FR-018: Chat Streaming & Master Chat (MS19) — COMPLETE
|
### FR-018: Chat Streaming & Master Chat (MS19)
|
||||||
|
|
||||||
- Complete SSE streaming for token-by-token chat rendering
|
- Complete SSE streaming for token-by-token chat rendering
|
||||||
- Master chat sidebar (ChatOverlay) polish: model selector, conversation search, keyboard shortcuts
|
- Master chat sidebar (ChatOverlay) polish: model selector, conversation search, keyboard shortcuts
|
||||||
- Chat persistence via Ideas API (already implemented)
|
- Chat persistence via Ideas API (already implemented)
|
||||||
- ASSUMPTION: Chat streaming uses existing SSE infrastructure in LLM controller. Frontend needs streamChatMessage() completion. Rationale: Backend SSE is already working, only frontend wiring is missing.
|
- ASSUMPTION: Chat streaming uses existing SSE infrastructure in LLM controller. Frontend needs streamChatMessage() completion. Rationale: Backend SSE is already working, only frontend wiring is missing.
|
||||||
- **Status: COMPLETE (MS19) — PRs #516 (streaming), #519 (polish). Model selector, temperature/maxTokens config, ChatEmptyState, Cmd+N/L shortcuts. 78 web tests.**
|
|
||||||
|
|
||||||
### FR-019: Project-Level Orchestrator Chat (MS19) — COMPLETE
|
### FR-019: Project-Level Orchestrator Chat (MS19)
|
||||||
|
|
||||||
- Chat context scoped to active project
|
- Chat context scoped to active project
|
||||||
- Can trigger orchestrator actions: spawn agent, check status, view jobs
|
- Can trigger orchestrator actions: spawn agent, check status, view jobs
|
||||||
- Command prefix system (/spawn, /status, /jobs) parsed in chat
|
- Command prefix system (/spawn, /status, /jobs) parsed in chat
|
||||||
- Agent output viewable in terminal tabs
|
- Agent output viewable in terminal tabs
|
||||||
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
|
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
|
||||||
- **Status: COMPLETE (MS19) — PRs #521 (commands), #522 (agent terminal). /status, /agents, /jobs, /pause, /resume, /help commands. Agent output streaming via SSE. 113 web tests.**
|
|
||||||
|
|
||||||
### FR-020: Settings Configuration (Future — MS20)
|
### FR-020: Settings Configuration (Future — MS20)
|
||||||
|
|
||||||
@@ -402,19 +383,19 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
|||||||
39. ~~All features support all themes~~ DONE
|
39. ~~All features support all themes~~ DONE
|
||||||
40. ~~Lint, typecheck, tests pass~~ DONE
|
40. ~~Lint, typecheck, tests pass~~ DONE
|
||||||
|
|
||||||
### MS19 — Chat & Terminal — COMPLETE
|
### MS19 — Chat & Terminal
|
||||||
|
|
||||||
41. ~~Terminal panel has real xterm.js with PTY backend~~ DONE — PR #518
|
41. Terminal panel has real xterm.js with PTY backend
|
||||||
42. ~~Terminal supports multiple named sessions (tabs)~~ DONE — PR #520
|
42. Terminal supports multiple named sessions (tabs)
|
||||||
43. ~~Terminal sessions persist and recover on reconnect~~ DONE — PR #517
|
43. Terminal sessions persist and recover on reconnect
|
||||||
44. ~~Chat streaming renders tokens in real-time (SSE)~~ DONE — PR #516
|
44. Chat streaming renders tokens in real-time (SSE)
|
||||||
45. ~~Master chat sidebar accessible from any page (Cmd+Shift+J)~~ DONE — PR #519
|
45. Master chat sidebar accessible from any page (Cmd+Shift+J)
|
||||||
46. ~~Master chat supports model selection and conversation management~~ DONE — PR #519
|
46. Master chat supports model selection and conversation management
|
||||||
47. ~~Project-level chat can trigger orchestrator actions~~ DONE — PR #521
|
47. Project-level chat can trigger orchestrator actions
|
||||||
48. ~~Agent output viewable in terminal tabs~~ DONE — PR #522
|
48. Agent output viewable in terminal tabs
|
||||||
49. ~~All features support all themes~~ DONE — CSS variables throughout
|
49. All features support all themes
|
||||||
50. ~~Lint, typecheck, tests pass~~ DONE — 1441 web + 3303 API = 4744 total
|
50. Lint, typecheck, tests pass
|
||||||
51. ~~Deployed and smoke-tested~~ DONE — CI #635 green, web deployed to mosaic.woltje.com
|
51. Deployed and smoke-tested
|
||||||
|
|
||||||
### Full Project (All Milestones)
|
### Full Project (All Milestones)
|
||||||
|
|
||||||
@@ -495,7 +476,7 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
|
|||||||
| Go-Live MVP | 0.1.0 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
| Go-Live MVP | 0.1.0 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
||||||
| MS16+MS17-PagesDataIntegration | 0.1.1 | All pages built + wired to real API data | COMPLETE |
|
| MS16+MS17-PagesDataIntegration | 0.1.1 | All pages built + wired to real API data | COMPLETE |
|
||||||
| MS18-ThemeWidgets | 0.1.2 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
| MS18-ThemeWidgets | 0.1.2 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
||||||
| MS19-ChatTerminal | 0.1.3 | Global terminal, project chat, master chat session | COMPLETE |
|
| MS19-ChatTerminal | 0.1.x | Global terminal, project chat, master chat session | NOT STARTED |
|
||||||
| MS20-MultiTenant | 0.2.0 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
| MS20-MultiTenant | 0.2.0 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||||
| MS21-Federation | 0.2.x | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
| MS21-Federation | 0.2.x | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||||
| MS22-AgentTelemetry | 0.2.x | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
| MS22-AgentTelemetry | 0.2.x | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||||
|
|||||||
@@ -2,31 +2,30 @@
|
|||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||||
| ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------------------------------------------- | ----------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ----------------------------------------------------------------- |
|
| ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------------------------------------------- | ----------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ---------------------------------------------------------------- |
|
||||||
| CT-PLAN-001 | done | Plan MS19 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | CT-TERM-001,CT-TERM-002,CT-CHAT-001,CT-CHAT-002 | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~15K | Planning complete |
|
| CT-PLAN-001 | done | Plan MS19 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | CT-TERM-001,CT-TERM-002,CT-CHAT-001,CT-CHAT-002 | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~15K | Planning complete |
|
||||||
| CT-TERM-001 | done | Terminal WebSocket gateway & PTY session service — NestJS gateway (namespace: /terminal), node-pty spawn/kill/resize, workspace-scoped rooms, auth via token | #508 | api | feat/ms19-terminal-gateway | CT-PLAN-001 | CT-TERM-003,CT-TERM-004,CT-ORCH-002 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #515 merged (6290fc3), 48 tests |
|
| CT-TERM-001 | not-started | Terminal WebSocket gateway & PTY session service — NestJS gateway (namespace: /terminal), node-pty spawn/kill/resize, workspace-scoped rooms, auth via token | #508 | api | feat/ms19-terminal-gateway | CT-PLAN-001 | CT-TERM-003,CT-TERM-004,CT-ORCH-002 | | | | 30K | | Follow speech gateway pattern |
|
||||||
| CT-TERM-002 | done | Terminal session persistence — Prisma model (TerminalSession: id, workspaceId, name, status, createdAt, closedAt), migration, CRUD service | #508 | api | feat/ms19-terminal-persistence | CT-PLAN-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #517 merged (8128eb7), 12 tests, #508 closed |
|
| CT-TERM-002 | not-started | Terminal session persistence — Prisma model (TerminalSession: id, workspaceId, name, status, createdAt, closedAt), migration, CRUD service | #508 | api | feat/ms19-terminal-persistence | CT-PLAN-001 | CT-TERM-004 | | | | 15K | | |
|
||||||
| CT-TERM-003 | done | xterm.js integration — Replace mock TerminalPanel with real xterm.js, WebSocket connection to /terminal namespace, resize handling, copy/paste, theme support | #509 | web | feat/ms19-xterm-integration | CT-TERM-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #518 merged (417c6ab), 40 tests |
|
| CT-TERM-003 | not-started | xterm.js integration — Replace mock TerminalPanel with real xterm.js, WebSocket connection to /terminal namespace, resize handling, copy/paste, theme support | #509 | web | feat/ms19-xterm-integration | CT-TERM-001 | CT-TERM-004 | | | | 30K | | Install @xterm/xterm + @xterm/addon-fit + @xterm/addon-web-links |
|
||||||
| CT-TERM-004 | done | Terminal tab management — Multiple named sessions, create/close/rename tabs, tab switching, session list from API, reconnect on page reload | #509 | web | feat/ms19-terminal-tabs | CT-TERM-001,CT-TERM-002,CT-TERM-003 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 20K | ~20K | PR #520 merged (859dcfc), 76 tests, #509 closed |
|
| CT-TERM-004 | not-started | Terminal tab management — Multiple named sessions, create/close/rename tabs, tab switching, session list from API, reconnect on page reload | #509 | web | feat/ms19-terminal-tabs | CT-TERM-001,CT-TERM-002,CT-TERM-003 | CT-VER-001 | | | | 20K | | |
|
||||||
| CT-CHAT-001 | done | Complete SSE chat streaming — Wire streamChatMessage() in frontend, token-by-token rendering in MessageList, streaming state indicators, abort/cancel support | #510 | web | feat/ms19-chat-streaming-v2 | CT-PLAN-001 | CT-CHAT-002,CT-ORCH-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #516 merged (7de0e73), streaming+fallback+abort |
|
| CT-CHAT-001 | not-started | Complete SSE chat streaming — Wire streamChatMessage() in frontend, token-by-token rendering in MessageList, streaming state indicators, abort/cancel support | #510 | web | feat/ms19-chat-streaming | CT-PLAN-001 | CT-CHAT-002,CT-ORCH-001 | | | | 25K | | Backend SSE already works, frontend TODO |
|
||||||
| CT-CHAT-002 | done | Master chat polish — Model selector dropdown, temperature/params config, conversation search in sidebar, keyboard shortcut improvements, empty state design | #510 | web | feat/ms19-chat-polish | CT-CHAT-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #519 merged (13aa52a), 46 tests, #510 closed |
|
| CT-CHAT-002 | not-started | Master chat polish — Model selector dropdown, temperature/params config, conversation search in sidebar, keyboard shortcut improvements, empty state design | #510 | web | feat/ms19-chat-polish | CT-CHAT-001 | CT-VER-001 | | | | 15K | | ChatOverlay ~95% done, needs finishing touches |
|
||||||
| CT-ORCH-001 | done | Project-level orchestrator chat — Chat context scoped to project, command prefix parsing (/spawn, /status, /jobs, /kill), route commands through orchestrator proxy, display structured responses | #511 | web | feat/ms19-orchestrator-chat | CT-CHAT-001 | CT-ORCH-002,CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~25K | PR #521 merged (b110c46), 34 tests |
|
| CT-ORCH-001 | not-started | Project-level orchestrator chat — Chat context scoped to project, command prefix parsing (/spawn, /status, /jobs, /kill), route commands through orchestrator proxy, display structured responses | #511 | web | feat/ms19-orchestrator-chat | CT-CHAT-001 | CT-ORCH-002,CT-VER-001 | | | | 30K | | Uses existing /api/orchestrator/\* proxy |
|
||||||
| CT-ORCH-002 | done | Agent output in terminal — View orchestrator agent sessions as terminal tabs, stream agent stdout/stderr via SSE (/agents/events), agent lifecycle indicators (spawning/running/done) | #511 | web | feat/ms19-agent-terminal | CT-TERM-001,CT-ORCH-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #522 merged (9b2520c), 79 tests, #511 closed |
|
| CT-ORCH-002 | not-started | Agent output in terminal — View orchestrator agent sessions as terminal tabs, stream agent stdout/stderr via SSE (/agents/events), agent lifecycle indicators (spawning/running/done) | #511 | web | feat/ms19-agent-terminal | CT-TERM-001,CT-ORCH-001 | CT-VER-001 | | | | 25K | | Orchestrator already has SSE at /agents/events |
|
||||||
| CT-VER-001 | done | Unit tests — Tests for terminal gateway, xterm component, chat streaming, orchestrator chat, agent terminal integration | #512 | web,api | — | CT-TERM-004,CT-CHAT-002,CT-ORCH-001,CT-ORCH-002 | CT-DOC-001 | orchestrator | 2026-02-25 | 2026-02-25 | 20K | ~5K | 328 MS19 tests (268 web + 60 API), all inline with tasks |
|
| CT-VER-001 | not-started | Unit tests — Tests for terminal gateway, xterm component, chat streaming, orchestrator chat, agent terminal integration | #512 | web,api | feat/ms19-tests | CT-TERM-004,CT-CHAT-002,CT-ORCH-001,CT-ORCH-002 | CT-DOC-001 | | | | 20K | | |
|
||||||
| CT-DOC-001 | done | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #512 | — | — | CT-VER-001 | CT-VER-002 | orchestrator | 2026-02-25 | 2026-02-25 | 10K | ~5K | Updated PRD, manifest, scratchpad, TASKS.md |
|
| CT-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #512 | — | — | CT-VER-001 | CT-VER-002 | orchestrator | | | 10K | | |
|
||||||
| CT-VER-002 | done | Deploy + smoke test — Deploy to Portainer, verify terminal, chat streaming, orchestrator chat, agent output all functional | #512 | — | — | CT-DOC-001 | | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~5K | CI #635 green, web deployed (sha:7165e7a), API crash pre-existing |
|
| CT-VER-002 | not-started | Deploy + smoke test — Deploy to Portainer, verify terminal, chat streaming, orchestrator chat, agent output all functional | #512 | — | — | CT-DOC-001 | | orchestrator | | | 15K | | |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| --------------- | ----------------- |
|
| --------------- | ----------------- |
|
||||||
| Total tasks | 12 |
|
| Total tasks | 12 |
|
||||||
| Completed | 12 |
|
| Completed | 1 (planning) |
|
||||||
| In Progress | 0 |
|
| In Progress | 0 |
|
||||||
| Remaining | 0 |
|
| Remaining | 11 |
|
||||||
| Estimated total | ~250K tokens |
|
| Estimated total | ~250K tokens |
|
||||||
| Used | ~215K tokens |
|
|
||||||
| Milestone | MS19-ChatTerminal |
|
| Milestone | MS19-ChatTerminal |
|
||||||
|
|
||||||
## Dependency Graph
|
## Dependency Graph
|
||||||
|
|||||||
@@ -86,34 +86,3 @@ MS18 is complete. Coolify deprecated, Portainer migration in progress with anoth
|
|||||||
- Created TASKS.md with 12-task breakdown (~250K token estimate)
|
- Created TASKS.md with 12-task breakdown (~250K token estimate)
|
||||||
- Created this scratchpad
|
- Created this scratchpad
|
||||||
- Archived MS18 TASKS.md to docs/tasks/MS18-ThemeWidgets-tasks.md
|
- Archived MS18 TASKS.md to docs/tasks/MS18-ThemeWidgets-tasks.md
|
||||||
|
|
||||||
### S2 — 2026-02-25
|
|
||||||
|
|
||||||
- Fixed CSRF token issue affecting API route health checks
|
|
||||||
- Wave 1 dispatch: CT-TERM-001 (terminal gateway) + CT-CHAT-001 (chat streaming) in parallel sonnet workers
|
|
||||||
- CT-TERM-001 → PR #515 merged (6290fc3), 48 tests
|
|
||||||
- CT-CHAT-001 → PR #516 merged (7de0e73), streaming + fallback + abort
|
|
||||||
- Wave 2 dispatch: CT-TERM-002 (persistence) + CT-TERM-003 (xterm.js) in parallel sonnet workers
|
|
||||||
- CT-TERM-002 → PR #517 merged (8128eb7), 12 tests, #508 closed
|
|
||||||
- CT-TERM-003 → PR #518 merged (417c6ab), 40 tests
|
|
||||||
- Context exhaustion after 5 tasks
|
|
||||||
|
|
||||||
### S3 — 2026-02-25
|
|
||||||
|
|
||||||
- Resume: Re-applied lost S2 TASKS.md edits (git stash during S2 cleanup lost docs)
|
|
||||||
- Wave 3a dispatch: CT-TERM-004 + CT-CHAT-002 in parallel sonnet workers
|
|
||||||
- CT-CHAT-002 → PR #519 merged (13aa52a), 46 tests, #510 closed
|
|
||||||
- CT-TERM-004 → PR #520 merged (859dcfc), 76 tests, #509 closed
|
|
||||||
- Wave 3b dispatch: CT-ORCH-001 (orchestrator chat) as sonnet worker
|
|
||||||
- CT-ORCH-001 → PR #521 merged (b110c46), 34 tests
|
|
||||||
- Wave 4 dispatch: CT-ORCH-002 (agent terminal) as sonnet worker
|
|
||||||
- CT-ORCH-002 worker completed with 79 tests, commit a0ceb30
|
|
||||||
- Context exhaustion before processing ORCH-002 output
|
|
||||||
|
|
||||||
### S4 — 2026-02-26
|
|
||||||
|
|
||||||
- Resume: Processed CT-ORCH-002 worker output from S3
|
|
||||||
- Cherry-picked a0ceb30 → clean branch → PR #522 merged (9b2520c), #511 closed
|
|
||||||
- CT-VER-001 verified: 328 MS19 tests (268 web + 60 API) across 15 test files, all passing
|
|
||||||
- CT-DOC-001: Updated TASKS.md, MISSION-MANIFEST.md, PRD.md, scratchpad
|
|
||||||
- Remaining: CT-VER-002 (deploy + smoke test)
|
|
||||||
|
|||||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -190,9 +190,6 @@ importers:
|
|||||||
matrix-bot-sdk:
|
matrix-bot-sdk:
|
||||||
specifier: ^0.8.0
|
specifier: ^0.8.0
|
||||||
version: 0.8.0
|
version: 0.8.0
|
||||||
node-pty:
|
|
||||||
specifier: ^1.0.0
|
|
||||||
version: 1.1.0
|
|
||||||
ollama:
|
ollama:
|
||||||
specifier: ^0.6.3
|
specifier: ^0.6.3
|
||||||
version: 0.6.3
|
version: 0.6.3
|
||||||
@@ -435,15 +432,6 @@ importers:
|
|||||||
'@types/dompurify':
|
'@types/dompurify':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
'@xterm/addon-fit':
|
|
||||||
specifier: ^0.11.0
|
|
||||||
version: 0.11.0
|
|
||||||
'@xterm/addon-web-links':
|
|
||||||
specifier: ^0.12.0
|
|
||||||
version: 0.12.0
|
|
||||||
'@xterm/xterm':
|
|
||||||
specifier: ^6.0.0
|
|
||||||
version: 6.0.0
|
|
||||||
'@xyflow/react':
|
'@xyflow/react':
|
||||||
specifier: ^12.5.3
|
specifier: ^12.5.3
|
||||||
version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -1596,6 +1584,7 @@ packages:
|
|||||||
|
|
||||||
'@mosaicstack/telemetry-client@0.1.1':
|
'@mosaicstack/telemetry-client@0.1.1':
|
||||||
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
@@ -3525,15 +3514,6 @@ packages:
|
|||||||
'@webassemblyjs/wast-printer@1.14.1':
|
'@webassemblyjs/wast-printer@1.14.1':
|
||||||
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
|
||||||
|
|
||||||
'@xterm/addon-fit@0.11.0':
|
|
||||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
|
||||||
|
|
||||||
'@xterm/addon-web-links@0.12.0':
|
|
||||||
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
|
||||||
|
|
||||||
'@xterm/xterm@6.0.0':
|
|
||||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|
||||||
@@ -5894,9 +5874,6 @@ packages:
|
|||||||
node-abort-controller@3.1.1:
|
node-abort-controller@3.1.1:
|
||||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||||
|
|
||||||
node-addon-api@7.1.1:
|
|
||||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
|
||||||
|
|
||||||
node-downloader-helper@2.1.10:
|
node-downloader-helper@2.1.10:
|
||||||
resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==}
|
resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==}
|
||||||
engines: {node: '>=14.18'}
|
engines: {node: '>=14.18'}
|
||||||
@@ -5921,9 +5898,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
node-pty@1.1.0:
|
|
||||||
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
|
||||||
|
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
@@ -7965,7 +7939,7 @@ snapshots:
|
|||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.4
|
dotenv: 17.2.4
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
open: 10.2.0
|
open: 10.2.0
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prettier: 3.8.1
|
prettier: 3.8.1
|
||||||
@@ -11030,12 +11004,6 @@ snapshots:
|
|||||||
'@webassemblyjs/ast': 1.14.1
|
'@webassemblyjs/ast': 1.14.1
|
||||||
'@xtuc/long': 4.2.2
|
'@xtuc/long': 4.2.2
|
||||||
|
|
||||||
'@xterm/addon-fit@0.11.0': {}
|
|
||||||
|
|
||||||
'@xterm/addon-web-links@0.12.0': {}
|
|
||||||
|
|
||||||
'@xterm/xterm@6.0.0': {}
|
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0': {}
|
'@xtuc/ieee754@1.2.0': {}
|
||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
@@ -11291,7 +11259,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -11316,7 +11284,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
better-sqlite3: 12.6.2
|
better-sqlite3: 12.6.2
|
||||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
@@ -12135,17 +12103,6 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.4: {}
|
dotenv@17.2.4: {}
|
||||||
|
|
||||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
|
||||||
optionalDependencies:
|
|
||||||
'@opentelemetry/api': 1.9.0
|
|
||||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
|
||||||
'@types/pg': 8.16.0
|
|
||||||
better-sqlite3: 12.6.2
|
|
||||||
kysely: 0.28.10
|
|
||||||
pg: 8.17.2
|
|
||||||
postgres: 3.4.8
|
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
|
||||||
|
|
||||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -12156,7 +12113,6 @@ snapshots:
|
|||||||
pg: 8.17.2
|
pg: 8.17.2
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||||
optional: true
|
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13497,8 +13453,6 @@ snapshots:
|
|||||||
|
|
||||||
node-abort-controller@3.1.1: {}
|
node-abort-controller@3.1.1: {}
|
||||||
|
|
||||||
node-addon-api@7.1.1: {}
|
|
||||||
|
|
||||||
node-downloader-helper@2.1.10: {}
|
node-downloader-helper@2.1.10: {}
|
||||||
|
|
||||||
node-emoji@1.11.0:
|
node-emoji@1.11.0:
|
||||||
@@ -13516,10 +13470,6 @@ snapshots:
|
|||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
node-pty@1.1.0:
|
|
||||||
dependencies:
|
|
||||||
node-addon-api: 7.1.1
|
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user