feat: implement Agent Client Protocol (ACP) frontend POC

Add complete coding agents feature with:
- Full vertical slice architecture in /features/coding-agents
- TanStack Query for all data fetching and session management
- Direct connection to ACP backend on localhost:3001
- Session creation with working directory selection
- Chat interface for sending prompts and viewing responses
- Automatic session recreation on backend restart (404 detection)
- Proper content block format for prompts
- Path validation requiring absolute paths
- Real-time session status display
- Protocol capabilities display

Key components:
- CodingAgentsView: Main view with directory selection
- ChatInterface: Message handling and display
- FolderSelector: Directory input with validation
- acpService: Direct HTTP connection to ACP backend
- useCodingAgentSession: Session lifecycle management
- useCodingAgentChat: Message state and prompt handling

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Rasmus Widing
2025-09-08 21:12:20 +03:00
parent 012d2c58ed
commit 248dbcddfa
21 changed files with 1305 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
import { SettingsPage } from './pages/SettingsPage';
import { MCPPage } from './pages/MCPPage';
import { OnboardingPage } from './pages/OnboardingPage';
import CodingAgents from './pages/CodingAgents';
import { MainLayout } from './components/layout/MainLayout';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -49,6 +50,7 @@ const AppRoutes = () => {
<Route path="/" element={<KnowledgeBasePage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/coding-agents" element={<CodingAgents />} />
<Route path="/mcp" element={<MCPPage />} />
{projectsEnabled ? (
<>

View File

@@ -1,4 +1,4 @@
import { BookOpen, Settings } from "lucide-react";
import { BookOpen, Settings, Code2 } from "lucide-react";
import type React from "react";
import { Link, useLocation } from "react-router-dom";
// TEMPORARY: Use old SettingsContext until settings are migrated
@@ -34,6 +34,12 @@ export function Navigation({ className }: NavigationProps) {
label: "Knowledge Base",
enabled: true,
},
{
path: "/coding-agents",
icon: <Code2 className="h-5 w-5" />,
label: "Coding Agents",
enabled: true,
},
{
path: "/mcp",
icon: (

View File

@@ -0,0 +1,95 @@
import { useEffect } from "react";
import { useCodingAgentChat, useCodingAgentSession } from "../hooks";
import { MessageList } from "./MessageList";
import { MessageInput } from "./MessageInput";
import { SessionStatus } from "./SessionStatus";
interface ChatInterfaceProps {
workingDirectory: string;
}
export function ChatInterface({ workingDirectory }: ChatInterfaceProps) {
const {
currentSessionId,
session,
isSessionLoading,
createSession,
cancelSession,
isCreatingSession,
isCancellingSession,
} = useCodingAgentSession();
const {
messages,
isProcessing,
sendMessage,
clearMessages,
} = useCodingAgentChat(currentSessionId);
// Create session when working directory is set
useEffect(() => {
if (workingDirectory && !currentSessionId && !isCreatingSession) {
createSession({
workingDirectory,
mcpServers: [] // Explicitly pass empty array for now
});
}
}, [workingDirectory, currentSessionId, isCreatingSession, createSession]);
// Clear messages when session changes
useEffect(() => {
if (currentSessionId) {
clearMessages();
}
}, [currentSessionId, clearMessages]);
const handleCancel = () => {
if (currentSessionId && !isCancellingSession) {
cancelSession();
}
};
const isDisabled = !currentSessionId ||
session?.status === "error" ||
session?.status === "cancelled" ||
isSessionLoading ||
isCreatingSession;
return (
<div className="flex flex-col h-full bg-gray-900/50 rounded-lg border border-gray-700 overflow-hidden">
{/* Header with session status */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 bg-gray-900/30">
<h3 className="text-lg font-semibold text-gray-100">Coding Agent Chat</h3>
<SessionStatus
status={session?.status}
sessionId={currentSessionId}
/>
</div>
{/* Messages area */}
<MessageList
messages={messages}
isProcessing={isProcessing}
/>
{/* Input area */}
<MessageInput
onSend={sendMessage}
onCancel={handleCancel}
disabled={isDisabled}
isProcessing={isProcessing}
placeholder={
!workingDirectory
? "Select a working directory first..."
: isCreatingSession
? "Creating session..."
: session?.status === "error"
? "Session error - please refresh"
: session?.status === "cancelled"
? "Session cancelled - please refresh"
: "Ask the coding agent to help with your code..."
}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { Folder, FolderOpen } from "lucide-react";
import { useState, useRef } from "react";
import { Button } from "../../ui/primitives/button";
import { Input } from "../../ui/primitives/input";
interface FolderSelectorProps {
value: string;
onChange: (path: string) => void;
disabled?: boolean;
placeholder?: string;
label?: string;
}
export function FolderSelector({
value,
onChange,
disabled = false,
placeholder = "Enter full absolute path (e.g., /Users/name/Projects/myproject)",
label = "Working Directory",
}: FolderSelectorProps) {
const [isSelecting, setIsSelecting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Handle folder selection via file input
const handleFolderSelect = () => {
// Create a hidden input element for folder selection
const input = document.createElement("input");
input.type = "file";
input.webkitdirectory = true;
input.directory = true;
input.onchange = (e: any) => {
const files = e.target.files;
if (files && files.length > 0) {
// Extract the folder path from the first file
const path = files[0].webkitRelativePath || files[0].path || "";
const folderPath = path.split("/")[0];
// Browser security prevents getting the full path
// Only the folder name is available, which isn't enough
// Show a message to the user
const folderName = folderPath || "selected folder";
alert(`Browser security prevents accessing the full path.\n\nPlease manually enter the complete path to "${folderName}" in the input field.\n\nExample: /Users/username/Projects/${folderName}`);
// Don't update with just the folder name as it won't work
// onChange(folderPath || value);
}
setIsSelecting(false);
};
input.oncancel = () => {
setIsSelecting(false);
};
setIsSelecting(true);
input.click();
};
return (
<div className="space-y-2">
{label && (
<label className="text-sm font-medium text-gray-300">
{label}
</label>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || isSelecting}
placeholder={placeholder}
className="pl-10 bg-gray-900/50 border-gray-700 text-gray-100 placeholder:text-gray-500"
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleFolderSelect}
disabled={disabled || isSelecting}
className="bg-gray-900/50 border-gray-700 hover:bg-gray-800/50"
title="Browse for folder"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
{value && (
<p className="text-xs text-gray-500">
Selected: <span className="text-gray-400 font-mono">{value}</span>
</p>
)}
</div>
);
}
// Add type declarations for webkitdirectory
declare module "react" {
interface InputHTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
webkitdirectory?: boolean;
directory?: boolean;
}
}

View File

@@ -0,0 +1,96 @@
import { Send, Square } from "lucide-react";
import { useState, useRef, useEffect, KeyboardEvent } from "react";
import { Button } from "../../ui/primitives/button";
interface MessageInputProps {
onSend: (message: string) => void;
onCancel?: () => void;
disabled?: boolean;
isProcessing?: boolean;
placeholder?: string;
}
export function MessageInput({
onSend,
onCancel,
disabled = false,
isProcessing = false,
placeholder = "Type a message...",
}: MessageInputProps) {
const [message, setMessage] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, [message]);
const handleSend = () => {
const trimmedMessage = message.trim();
if (trimmedMessage && !disabled && !isProcessing) {
onSend(trimmedMessage);
setMessage("");
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="border-t border-gray-700 bg-gray-900/50 p-4">
<div className="flex gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled || isProcessing}
placeholder={placeholder}
className="w-full min-h-[44px] max-h-[200px] px-4 py-2.5 pr-12 bg-gray-800/50 border border-gray-700 rounded-lg text-gray-100 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 resize-none"
rows={1}
/>
<div className="absolute bottom-2 right-2 text-xs text-gray-500">
{message.length > 0 && `${message.length} chars`}
</div>
</div>
{isProcessing ? (
<Button
type="button"
variant="destructive"
size="icon"
onClick={onCancel}
className="h-[44px] w-[44px]"
title="Cancel operation"
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
onClick={handleSend}
disabled={!message.trim() || disabled}
className="h-[44px] px-4 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
title="Send message (Enter)"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
<div className="mt-2 text-xs text-gray-500">
Press <kbd className="px-1.5 py-0.5 bg-gray-800 border border-gray-700 rounded">Enter</kbd> to send,{" "}
<kbd className="px-1.5 py-0.5 bg-gray-800 border border-gray-700 rounded">Shift+Enter</kbd> for new line
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { Bot, User, AlertCircle, CheckCircle, Clock } from "lucide-react";
import { useEffect, useRef } from "react";
import type { ChatMessage } from "../types";
interface MessageListProps {
messages: ChatMessage[];
isProcessing?: boolean;
}
export function MessageList({ messages, isProcessing = false }: MessageListProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const getStatusIcon = (status?: ChatMessage["status"]) => {
switch (status) {
case "sending":
return <Clock className="h-3 w-3 animate-pulse" />;
case "sent":
return <CheckCircle className="h-3 w-3" />;
case "error":
return <AlertCircle className="h-3 w-3 text-red-400" />;
default:
return null;
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && !isProcessing && (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Bot className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm">Start a conversation with the coding agent</p>
<p className="text-xs mt-2">Select a working directory and send a message to begin</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
{message.role === "assistant" && (
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
<Bot className="h-5 w-5 text-white" />
</div>
</div>
)}
<div
className={`max-w-[70%] ${
message.role === "user" ? "order-1" : ""
}`}
>
<div
className={`rounded-lg px-4 py-2 ${
message.role === "user"
? "bg-blue-600/20 border border-blue-500/30 text-gray-100"
: "bg-gray-800/50 border border-gray-700 text-gray-100"
}`}
>
<pre className="whitespace-pre-wrap font-sans text-sm">
{message.content}
</pre>
{message.stopReason && (
<p className="text-xs text-gray-500 mt-2">
Status: {message.stopReason}
</p>
)}
</div>
<div className="flex items-center gap-2 mt-1 px-1">
<span className="text-xs text-gray-500">
{formatTimestamp(message.timestamp)}
</span>
{message.status && getStatusIcon(message.status)}
</div>
</div>
{message.role === "user" && (
<div className="flex-shrink-0 order-2">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-600 flex items-center justify-center">
<User className="h-5 w-5 text-white" />
</div>
</div>
)}
</div>
))}
{isProcessing && (
<div className="flex gap-3 justify-start">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
<Bot className="h-5 w-5 text-white" />
</div>
</div>
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-2">
<div className="flex gap-1">
<span className="h-2 w-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="h-2 w-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="h-2 w-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Activity, AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react";
import type { SessionStatus as SessionStatusType } from "../types";
interface SessionStatusProps {
status: SessionStatusType | undefined;
sessionId?: string | null;
className?: string;
}
export function SessionStatus({ status, sessionId, className = "" }: SessionStatusProps) {
const getStatusConfig = (status?: SessionStatusType) => {
switch (status) {
case "creating":
return {
icon: <Clock className="h-4 w-4 animate-pulse" />,
text: "Creating session...",
color: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30",
};
case "active":
return {
icon: <CheckCircle className="h-4 w-4" />,
text: "Session active",
color: "text-green-400 bg-green-400/10 border-green-400/30",
};
case "processing":
return {
icon: <Activity className="h-4 w-4 animate-pulse" />,
text: "Processing...",
color: "text-cyan-400 bg-cyan-400/10 border-cyan-400/30",
};
case "cancelled":
return {
icon: <XCircle className="h-4 w-4" />,
text: "Session cancelled",
color: "text-gray-400 bg-gray-400/10 border-gray-400/30",
};
case "error":
return {
icon: <AlertCircle className="h-4 w-4" />,
text: "Session error",
color: "text-red-400 bg-red-400/10 border-red-400/30",
};
default:
return {
icon: <Activity className="h-4 w-4 opacity-50" />,
text: "No active session",
color: "text-gray-500 bg-gray-500/10 border-gray-500/30",
};
}
};
const config = getStatusConfig(status);
return (
<div className={`flex items-center gap-2 ${className}`}>
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border ${config.color}`}
>
{config.icon}
<span className="text-sm font-medium">{config.text}</span>
</div>
{sessionId && status === "active" && (
<span className="text-xs text-gray-500 font-mono">
ID: {sessionId.slice(0, 8)}...
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { ChatInterface } from "./ChatInterface";
export { FolderSelector } from "./FolderSelector";
export { MessageList } from "./MessageList";
export { MessageInput } from "./MessageInput";
export { SessionStatus } from "./SessionStatus";

View File

@@ -0,0 +1,2 @@
export * from "./useCodingAgentSession";
export * from "./useCodingAgentChat";

View File

@@ -0,0 +1,152 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useCallback, useRef } from "react";
import { useToast } from "../../ui/hooks/useToast";
import { acpService } from "../services";
import type { ChatMessage, PromptInput, PromptResponse } from "../types";
import { codingAgentKeys } from "./useCodingAgentSession";
/**
* Hook to manage chat interactions with the coding agent
*/
export function useCodingAgentChat(sessionId: string | null) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const messageIdCounter = useRef(0);
// Generate unique message ID
const generateMessageId = useCallback(() => {
messageIdCounter.current += 1;
return `msg-${Date.now()}-${messageIdCounter.current}`;
}, []);
// Add a message to the chat
const addMessage = useCallback((
role: ChatMessage["role"],
content: string,
stopReason?: string,
) => {
const newMessage: ChatMessage = {
id: generateMessageId(),
role,
content,
timestamp: new Date().toISOString(),
status: role === "user" ? "sent" : undefined,
stopReason,
};
setMessages((prev) => [...prev, newMessage]);
return newMessage;
}, [generateMessageId]);
// Update message status
const updateMessageStatus = useCallback((
messageId: string,
status: ChatMessage["status"],
) => {
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, status } : msg,
),
);
}, []);
// Send prompt mutation
const sendPromptMutation = useMutation({
mutationFn: async (prompt: PromptInput) => {
if (!sessionId) throw new Error("No active session");
const response = await acpService.sendPrompt(sessionId, { prompt });
return response.data;
},
onMutate: async (prompt) => {
setIsProcessing(true);
// Add user message
const content = typeof prompt === "string"
? prompt
: prompt.map(block => block.text || block.title || "").join("\n");
const userMessage = addMessage("user", content);
updateMessageStatus(userMessage.id, "sending");
return { userMessageId: userMessage.id };
},
onSuccess: (data: PromptResponse, _variables, context) => {
// Update user message status
if (context?.userMessageId) {
updateMessageStatus(context.userMessageId, "sent");
}
// Add assistant response
const responseContent = data.stopReason === "error"
? "An error occurred while processing your request."
: "Task completed successfully.";
addMessage("assistant", responseContent, data.stopReason);
// Invalidate session status to get updated state
queryClient.invalidateQueries({
queryKey: codingAgentKeys.sessionStatus(sessionId!),
});
},
onError: (error, _variables, context) => {
// Update user message status
if (context?.userMessageId) {
updateMessageStatus(context.userMessageId, "error");
}
const message = error instanceof Error ? error.message : "Failed to send prompt";
// Check if it's a session-related error
if (message.includes("404") || message.includes("Session not found")) {
addMessage("assistant", "Session expired. Please refresh to create a new session.");
showToast("Session expired. Creating new session...", "info");
} else if (message.includes("Session did not end in result")) {
addMessage("assistant", "The AI agent failed to process your request. This might be due to a configuration issue with the ACP backend.");
showToast("AI processing error. Check ACP backend configuration.", "error");
} else {
addMessage("assistant", `Error: ${message}`);
showToast(message, "error");
}
},
onSettled: () => {
setIsProcessing(false);
},
});
// Clear chat history
const clearMessages = useCallback(() => {
setMessages([]);
messageIdCounter.current = 0;
}, []);
// Send a text prompt
const sendMessage = useCallback((text: string) => {
if (!text.trim()) return;
// Convert plain text to content block format expected by backend
const contentBlocks = [
{
type: "text",
text: text,
},
];
sendPromptMutation.mutate(contentBlocks);
}, [sendPromptMutation]);
return {
// State
messages,
isProcessing: isProcessing || sendPromptMutation.isPending,
// Actions
sendMessage,
sendPrompt: sendPromptMutation.mutate,
clearMessages,
// Utilities
addMessage,
updateMessageStatus,
};
}

View File

@@ -0,0 +1,148 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useToast } from "../../ui/hooks/useToast";
import { acpService } from "../services";
import type {
CreateSessionRequest,
InitializeResponse,
LoadSessionRequest,
Session,
} from "../types";
// Query keys factory
export const codingAgentKeys = {
all: ["coding-agent"] as const,
initialize: () => [...codingAgentKeys.all, "initialize"] as const,
sessions: () => [...codingAgentKeys.all, "sessions"] as const,
session: (id: string) => [...codingAgentKeys.sessions(), id] as const,
sessionStatus: (id: string) => [...codingAgentKeys.session(id), "status"] as const,
};
/**
* Hook to initialize the ACP connection
*/
export function useInitializeCodingAgent() {
return useQuery<InitializeResponse>({
queryKey: codingAgentKeys.initialize(),
queryFn: async () => {
const response = await acpService.initialize();
return response.data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 3,
});
}
/**
* Hook to manage coding agent sessions
*/
export function useCodingAgentSession() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
// Query for current session
const sessionQuery = useQuery<Session>({
queryKey: currentSessionId ? codingAgentKeys.session(currentSessionId) : ["no-session"],
queryFn: async () => {
if (!currentSessionId) throw new Error("No session ID");
try {
const response = await acpService.getSession(currentSessionId);
return response.data;
} catch (error) {
// If session not found (404), clear the invalid session ID
if (error instanceof Error && error.message.includes("404")) {
setCurrentSessionId(null);
showToast("Session expired. Please create a new session.", "info");
}
throw error;
}
},
enabled: !!currentSessionId,
retry: (failureCount, error) => {
// Don't retry on 404 errors (session doesn't exist)
if (error instanceof Error && error.message.includes("404")) {
return false;
}
// Retry other errors up to 3 times
return failureCount < 3;
},
refetchInterval: (data) => {
// Poll more frequently when session is processing
if (data?.status === "processing" || data?.status === "creating") {
return 1000; // 1 second
}
return false; // Don't poll otherwise
},
});
// Mutation to create a new session
const createSessionMutation = useMutation({
mutationFn: async (request: CreateSessionRequest) => {
const response = await acpService.createSession(request);
return response.data;
},
onSuccess: (data) => {
setCurrentSessionId(data.sessionId);
showToast("Session created successfully", "success");
// Invalidate session queries
queryClient.invalidateQueries({ queryKey: codingAgentKeys.sessions() });
},
onError: (error) => {
const message = error instanceof Error ? error.message : "Failed to create session";
showToast(message, "error");
},
});
// Mutation to load an existing session
const loadSessionMutation = useMutation({
mutationFn: async (request: LoadSessionRequest) => {
await acpService.loadSession(request);
return request.sessionId;
},
onSuccess: (sessionId) => {
setCurrentSessionId(sessionId);
showToast("Session loaded successfully", "success");
queryClient.invalidateQueries({ queryKey: codingAgentKeys.session(sessionId) });
},
onError: (error) => {
const message = error instanceof Error ? error.message : "Failed to load session";
showToast(message, "error");
},
});
// Mutation to cancel the current session
const cancelSessionMutation = useMutation({
mutationFn: async () => {
if (!currentSessionId) throw new Error("No active session");
await acpService.cancelSession(currentSessionId);
return currentSessionId;
},
onSuccess: (sessionId) => {
showToast("Session cancelled", "info");
queryClient.invalidateQueries({ queryKey: codingAgentKeys.session(sessionId) });
},
onError: (error) => {
const message = error instanceof Error ? error.message : "Failed to cancel session";
showToast(message, "error");
},
});
return {
// State
currentSessionId,
session: sessionQuery.data,
isSessionLoading: sessionQuery.isLoading,
sessionError: sessionQuery.error,
// Actions
createSession: createSessionMutation.mutate,
loadSession: loadSessionMutation.mutate,
cancelSession: cancelSessionMutation.mutate,
// Loading states
isCreatingSession: createSessionMutation.isPending,
isLoadingSession: loadSessionMutation.isPending,
isCancellingSession: cancelSessionMutation.isPending,
};
}

View File

@@ -0,0 +1,20 @@
// Main exports for coding-agents feature
export { CodingAgentsView } from "./views/CodingAgentsView";
export { CodingAgentsPage } from "./views/CodingAgentsPage";
// Export hooks if needed by other features
export {
useInitializeCodingAgent,
useCodingAgentSession,
useCodingAgentChat
} from "./hooks";
// Export types if needed by other features
export type {
Session,
SessionStatus,
ChatMessage,
InitializeResponse,
AgentCapabilitiesResponse,
PromptCapabilitiesResponse
} from "./types";

View File

@@ -0,0 +1,143 @@
/**
* Agent Client Protocol (ACP) Service
* Handles all communication with the ACP backend running on localhost:3001
*/
import type {
CreateSessionApiResponse,
CreateSessionRequest,
EmptyApiResponse,
InitializeApiResponse,
LoadSessionRequest,
PromptApiResponse,
PromptRequest,
SessionApiResponse,
} from "../types";
// Direct connection to ACP backend (CORS is configured to allow all origins)
const ACP_BASE_URL = "http://localhost:3001";
class AcpService {
private baseUrl: string;
constructor(baseUrl: string = ACP_BASE_URL) {
this.baseUrl = baseUrl;
}
private async request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
// Try to parse error as JSON for better error messages
let errorMessage = `${response.status} ${response.statusText}`;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error) {
errorMessage = errorJson.error;
} else if (errorJson.message) {
errorMessage = errorJson.message;
}
} catch {
// If not JSON, use the text as-is
if (errorText) {
errorMessage = errorText;
}
}
throw new Error(errorMessage);
}
return response.json();
}
/**
* Initialize the ACP connection
*/
async initialize(): Promise<InitializeApiResponse> {
return this.request<InitializeApiResponse>("/api/initialize", {
method: "POST",
});
}
/**
* Create a new session
*/
async createSession(
request: CreateSessionRequest,
): Promise<CreateSessionApiResponse> {
return this.request<CreateSessionApiResponse>("/api/sessions", {
method: "POST",
body: JSON.stringify(request),
});
}
/**
* Load an existing session
*/
async loadSession(request: LoadSessionRequest): Promise<EmptyApiResponse> {
return this.request<EmptyApiResponse>("/api/sessions/load", {
method: "POST",
body: JSON.stringify(request),
});
}
/**
* Get session details
*/
async getSession(sessionId: string): Promise<SessionApiResponse> {
return this.request<SessionApiResponse>(`/api/sessions/${sessionId}`);
}
/**
* Get session status
*/
async getSessionStatus(sessionId: string): Promise<SessionApiResponse> {
return this.request<SessionApiResponse>(
`/api/sessions/${sessionId}/status`,
);
}
/**
* Send a prompt to a session
*/
async sendPrompt(
sessionId: string,
request: PromptRequest,
): Promise<PromptApiResponse> {
console.log("[ACP] Sending prompt request:", JSON.stringify(request, null, 2));
return this.request<PromptApiResponse>(
`/api/sessions/${sessionId}/prompt`,
{
method: "POST",
body: JSON.stringify(request),
},
);
}
/**
* Cancel a session
*/
async cancelSession(sessionId: string): Promise<EmptyApiResponse> {
return this.request<EmptyApiResponse>(
`/api/sessions/${sessionId}/cancel`,
{
method: "POST",
},
);
}
}
export const acpService = new AcpService();

View File

@@ -0,0 +1 @@
export { acpService } from "./acpService";

View File

@@ -0,0 +1,60 @@
// Agent capabilities and initialization types
export interface AgentCapabilitiesResponse {
loadSession?: boolean | null;
promptCapabilities: PromptCapabilitiesResponse;
}
export interface PromptCapabilitiesResponse {
image: boolean;
embeddedContext: boolean;
audio?: boolean | null;
}
export interface ClientCapabilitiesResponse {
fs: FsCapabilitiesResponse;
}
export interface FsCapabilitiesResponse {
readTextFile: boolean;
writeTextFile: boolean;
}
export interface InitializeResponse {
protocolVersion: number;
capabilities: AgentCapabilitiesResponse;
clientCapabilities?: ClientCapabilitiesResponse | null;
}
// Content and prompt types
export interface ContentBlock {
type: string;
text?: string | null;
data?: string | null;
description?: string | null;
mimeType?: string | null;
name?: string | null;
resource?: any | null;
size?: number | null;
title?: string | null;
uri?: string | null;
}
export type PromptInput = string | ContentBlock[];
export interface PromptResponse {
stopReason: string;
}
// MCP Server configuration
export interface EnvVariable {
name: string;
value: string;
}
export interface McpServer {
name: string;
command: string;
args: string[];
env: EnvVariable[];
}

View File

@@ -0,0 +1,58 @@
import type { McpServer, PromptInput, PromptResponse } from "./CodingAgent";
// Session status enum
export type SessionStatus = "creating" | "active" | "processing" | "cancelled" | "error";
// Session management types
export interface Session {
id: string;
working_directory: string;
mcp_servers: McpServer[];
created_at: string;
status: SessionStatus;
}
export interface CreateSessionRequest {
workingDirectory: string;
mcpServers?: McpServer[];
}
export interface CreateSessionResponse {
sessionId: string;
}
export interface LoadSessionRequest {
sessionId: string;
workingDirectory: string;
mcpServers?: McpServer[];
}
// Prompt request/response
export interface PromptRequest {
prompt: PromptInput;
}
// API Response wrappers
export interface ApiResponse<T> {
data: T;
}
export interface EmptyApiResponse {
data?: null;
}
// Specific API response types
export interface InitializeApiResponse extends ApiResponse<import("./CodingAgent").InitializeResponse> {}
export interface CreateSessionApiResponse extends ApiResponse<CreateSessionResponse> {}
export interface SessionApiResponse extends ApiResponse<Session> {}
export interface PromptApiResponse extends ApiResponse<PromptResponse> {}
// Message types for chat interface
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: string;
status?: "sending" | "sent" | "error";
stopReason?: string;
}

View File

@@ -0,0 +1,2 @@
export * from "./CodingAgent";
export * from "./Session";

View File

@@ -0,0 +1,30 @@
import { ErrorBoundary } from "react-error-boundary";
import { CodingAgentsView } from "./CodingAgentsView";
import { AlertCircle } from "lucide-react";
import { Button } from "../../ui/primitives/button";
function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
return (
<div className="flex items-center justify-center h-full p-6">
<div className="text-center space-y-4 max-w-md">
<AlertCircle className="h-12 w-12 mx-auto text-red-500" />
<h2 className="text-xl font-semibold text-gray-100">Something went wrong</h2>
<p className="text-sm text-gray-400">{error.message}</p>
<Button
onClick={resetErrorBoundary}
className="bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500"
>
Try again
</Button>
</div>
</div>
);
}
export function CodingAgentsPage() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<CodingAgentsView />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,165 @@
import { useState } from "react";
import { Code2, Terminal } from "lucide-react";
import { useInitializeCodingAgent } from "../hooks";
import { ChatInterface } from "../components/ChatInterface";
import { FolderSelector } from "../components/FolderSelector";
import { Button } from "../../ui/primitives/button";
export function CodingAgentsView() {
const [workingDirectory, setWorkingDirectory] = useState("");
const [isSessionStarted, setIsSessionStarted] = useState(false);
// Initialize the coding agent connection
const { data: initData, isLoading: isInitializing, error: initError } = useInitializeCodingAgent();
const handleStartSession = () => {
// Validate that it's an absolute path
if (workingDirectory && workingDirectory.startsWith('/')) {
setIsSessionStarted(true);
} else {
alert('Please enter a full absolute path starting with "/"\n\nExample: /Users/username/Projects/myproject');
}
};
const handleReset = () => {
setIsSessionStarted(false);
setWorkingDirectory("");
};
if (isInitializing) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<Terminal className="h-12 w-12 mx-auto text-cyan-500 animate-pulse" />
<p className="text-gray-400">Initializing coding agent...</p>
</div>
</div>
);
}
if (initError) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4 max-w-md">
<Terminal className="h-12 w-12 mx-auto text-red-500" />
<p className="text-gray-300">Failed to initialize coding agent</p>
<p className="text-sm text-gray-500">
{initError instanceof Error ? initError.message : "Unknown error"}
</p>
<p className="text-xs text-gray-600">
Make sure the ACP backend is running on localhost:3001
</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-cyan-500 to-blue-600 rounded-lg">
<Code2 className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-100">Coding Agent</h1>
<p className="text-sm text-gray-400">
AI-powered coding assistant for your projects
</p>
</div>
</div>
{isSessionStarted && (
<Button
variant="outline"
onClick={handleReset}
className="bg-gray-900/50 border-gray-700 hover:bg-gray-800/50"
>
Change Directory
</Button>
)}
</div>
{/* Protocol info */}
{initData && (
<div className="bg-gray-900/30 border border-gray-700 rounded-lg p-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="text-gray-400">
Protocol v{initData.protocolVersion}
</span>
<span className="text-gray-500"></span>
<span className="text-gray-400">
Image support: {initData.capabilities.promptCapabilities.image ? "✓" : "✗"}
</span>
<span className="text-gray-500"></span>
<span className="text-gray-400">
Context: {initData.capabilities.promptCapabilities.embeddedContext ? "✓" : "✗"}
</span>
{initData.capabilities.promptCapabilities.audio && (
<>
<span className="text-gray-500"></span>
<span className="text-gray-400">Audio: </span>
</>
)}
</div>
{initData.clientCapabilities && (
<div className="flex items-center gap-4">
<span className="text-gray-400">
File read: {initData.clientCapabilities.fs.readTextFile ? "✓" : "✗"}
</span>
<span className="text-gray-400">
File write: {initData.clientCapabilities.fs.writeTextFile ? "✓" : "✗"}
</span>
</div>
)}
</div>
</div>
)}
{/* Main content */}
<div className="flex-1 overflow-hidden">
{!isSessionStarted ? (
<div className="h-full flex items-center justify-center">
<div className="max-w-md w-full space-y-6">
<div className="text-center space-y-2">
<Terminal className="h-16 w-16 mx-auto text-cyan-500 opacity-50" />
<h2 className="text-xl font-semibold text-gray-100">
Select a Working Directory
</h2>
<p className="text-sm text-gray-400">
Choose the folder where the coding agent will work on your project
</p>
</div>
<div className="bg-gray-900/50 border border-gray-700 rounded-lg p-6 space-y-4">
<FolderSelector
value={workingDirectory}
onChange={setWorkingDirectory}
label="Target Directory"
placeholder="/path/to/your/project"
/>
<Button
onClick={handleStartSession}
disabled={!workingDirectory || !workingDirectory.startsWith('/')}
className="w-full bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Start Coding Session
</Button>
</div>
<div className="text-xs text-gray-500 text-center space-y-1">
<p>The agent will have access to read and modify files in this directory</p>
<p>Make sure to choose the correct project folder</p>
</div>
</div>
</div>
) : (
<ChatInterface workingDirectory={workingDirectory} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { CodingAgentsPage } from "../features/coding-agents";
export default function CodingAgents() {
return <CodingAgentsPage />;
}

View File

@@ -305,6 +305,22 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
console.log('🔄 [VITE PROXY] Forwarding:', req.method, req.url, 'to', `http://${host}:${port}${req.url}`);
});
}
},
// Proxy for ACP backend on localhost:3001
'/acp': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/acp/, ''),
configure: (proxy, options) => {
proxy.on('error', (err, req, res) => {
console.log('🚨 [ACP PROXY ERROR]:', err.message);
console.log('🚨 [ACP PROXY ERROR] Request:', req.url);
});
proxy.on('proxyReq', (proxyReq, req, res) => {
console.log('🔄 [ACP PROXY] Forwarding:', req.method, req.url, 'to localhost:3001');
});
}
}
},
},