feat: Enable assistant chat to create and manage features

Allow users to interact with the project assistant to create features
through natural conversation. The assistant can now:

- Create single features via `feature_create` tool
- Create multiple features via `feature_create_bulk`
- Skip features to deprioritize them via `feature_skip`

Changes:
- Add `feature_create` MCP tool for single-feature creation
- Update assistant allowed tools to include feature management
- Update system prompt to explain new capabilities
- Enhance UI tool call display with friendly messages

Security: File system access remains read-only. The assistant cannot
modify source code or mark features as passing (requires actual
implementation by the coding agent).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Connor Tyndall
2026-01-09 06:10:43 -06:00
parent 122f03dc21
commit 398c9d492f
3 changed files with 302 additions and 154 deletions

337
ui/src/hooks/useAssistantChat.ts Normal file → Executable file
View File

@@ -2,120 +2,129 @@
* Hook for managing assistant chat WebSocket connection
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, AssistantChatServerMessage } from '../lib/types'
import { useState, useCallback, useRef, useEffect } from "react";
import type { ChatMessage, AssistantChatServerMessage } from "../lib/types";
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
interface UseAssistantChatOptions {
projectName: string
onError?: (error: string) => void
projectName: string;
onError?: (error: string) => void;
}
interface UseAssistantChatReturn {
messages: ChatMessage[]
isLoading: boolean
connectionStatus: ConnectionStatus
conversationId: number | null
start: (conversationId?: number | null) => void
sendMessage: (content: string) => void
disconnect: () => void
clearMessages: () => void
messages: ChatMessage[];
isLoading: boolean;
connectionStatus: ConnectionStatus;
conversationId: number | null;
start: (conversationId?: number | null) => void;
sendMessage: (content: string) => void;
disconnect: () => void;
clearMessages: () => void;
}
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
export function useAssistantChat({
projectName,
onError,
}: UseAssistantChatOptions): UseAssistantChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
const [conversationId, setConversationId] = useState<number | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("disconnected");
const [conversationId, setConversationId] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(null)
const currentAssistantMessageRef = useRef<string | null>(null)
const reconnectAttempts = useRef(0)
const maxReconnectAttempts = 3
const pingIntervalRef = useRef<number | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
const wsRef = useRef<WebSocket | null>(null);
const currentAssistantMessageRef = useRef<string | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
const pingIntervalRef = useRef<number | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
// Clean up on unmount
useEffect(() => {
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
clearInterval(pingIntervalRef.current);
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current.close();
}
}
}, [])
};
}, []);
const connect = useCallback(() => {
// Prevent multiple connection attempts
if (wsRef.current?.readyState === WebSocket.OPEN ||
wsRef.current?.readyState === WebSocket.CONNECTING) {
return
if (
wsRef.current?.readyState === WebSocket.OPEN ||
wsRef.current?.readyState === WebSocket.CONNECTING
) {
return;
}
setConnectionStatus('connecting')
setConnectionStatus("connecting");
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/assistant/ws/${encodeURIComponent(projectName)}`
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const wsUrl = `${protocol}//${host}/api/assistant/ws/${encodeURIComponent(projectName)}`;
const ws = new WebSocket(wsUrl)
wsRef.current = ws
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setConnectionStatus('connected')
reconnectAttempts.current = 0
setConnectionStatus("connected");
reconnectAttempts.current = 0;
// Start ping interval to keep connection alive
pingIntervalRef.current = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000)
}
}, 30000);
};
ws.onclose = () => {
setConnectionStatus('disconnected')
setConnectionStatus("disconnected");
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Attempt reconnection if not intentionally closed
if (reconnectAttempts.current < maxReconnectAttempts) {
reconnectAttempts.current++
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
reconnectTimeoutRef.current = window.setTimeout(connect, delay)
reconnectAttempts.current++;
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts.current),
10000,
);
reconnectTimeoutRef.current = window.setTimeout(connect, delay);
}
}
};
ws.onerror = () => {
setConnectionStatus('error')
onError?.('WebSocket connection error')
}
setConnectionStatus("error");
onError?.("WebSocket connection error");
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as AssistantChatServerMessage
const data = JSON.parse(event.data) as AssistantChatServerMessage;
switch (data.type) {
case 'text': {
case "text": {
// Append text to current assistant message or create new one
setMessages((prev) => {
const lastMessage = prev[prev.length - 1]
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
const lastMessage = prev[prev.length - 1];
if (
lastMessage?.role === "assistant" &&
lastMessage.isStreaming
) {
// Append to existing streaming message
return [
...prev.slice(0, -1),
@@ -123,155 +132,197 @@ export function useAssistantChat({
...lastMessage,
content: lastMessage.content + data.content,
},
]
];
} else {
// Create new assistant message
currentAssistantMessageRef.current = generateId()
currentAssistantMessageRef.current = generateId();
return [
...prev,
{
id: currentAssistantMessageRef.current,
role: 'assistant',
role: "assistant",
content: data.content,
timestamp: new Date(),
isStreaming: true,
},
]
];
}
})
break
});
break;
}
case 'tool_call': {
case "tool_call": {
// Generate user-friendly tool descriptions
let toolDescription = `Using tool: ${data.tool}`;
if (data.tool === "mcp__features__feature_create") {
const input = data.input as { name?: string; category?: string };
toolDescription = `Creating feature: "${input.name || "New Feature"}" in ${input.category || "General"}`;
} else if (data.tool === "mcp__features__feature_create_bulk") {
const input = data.input as {
features?: Array<{ name: string }>;
};
const count = input.features?.length || 0;
toolDescription = `Creating ${count} feature${count !== 1 ? "s" : ""}`;
} else if (data.tool === "mcp__features__feature_skip") {
toolDescription = `Skipping feature (moving to end of queue)`;
} else if (data.tool === "mcp__features__feature_get_stats") {
toolDescription = `Checking project progress`;
} else if (data.tool === "mcp__features__feature_get_next") {
toolDescription = `Getting next pending feature`;
} else if (data.tool === "Read") {
const input = data.input as { file_path?: string };
const path = input.file_path || "";
const filename = path.split("/").pop() || path;
toolDescription = `Reading file: ${filename}`;
} else if (data.tool === "Glob") {
const input = data.input as { pattern?: string };
toolDescription = `Searching for files: ${input.pattern || "..."}`;
} else if (data.tool === "Grep") {
const input = data.input as { pattern?: string };
toolDescription = `Searching for: ${input.pattern || "..."}`;
}
// Show tool call as system message
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'system',
content: `Using tool: ${data.tool}`,
role: "system",
content: toolDescription,
timestamp: new Date(),
},
])
break
]);
break;
}
case 'conversation_created': {
setConversationId(data.conversation_id)
break
case "conversation_created": {
setConversationId(data.conversation_id);
break;
}
case 'response_done': {
setIsLoading(false)
case "response_done": {
setIsLoading(false);
// Mark current message as done streaming
setMessages((prev) => {
const lastMessage = prev[prev.length - 1]
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
const lastMessage = prev[prev.length - 1];
if (
lastMessage?.role === "assistant" &&
lastMessage.isStreaming
) {
return [
...prev.slice(0, -1),
{ ...lastMessage, isStreaming: false },
]
];
}
return prev
})
break
return prev;
});
break;
}
case 'error': {
setIsLoading(false)
onError?.(data.content)
case "error": {
setIsLoading(false);
onError?.(data.content);
// Add error as system message
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'system',
role: "system",
content: `Error: ${data.content}`,
timestamp: new Date(),
},
])
break
]);
break;
}
case 'pong': {
case "pong": {
// Keep-alive response, nothing to do
break
break;
}
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
console.error("Failed to parse WebSocket message:", e);
}
}
}, [projectName, onError])
};
}, [projectName, onError]);
const start = useCallback((existingConversationId?: number | null) => {
connect()
const start = useCallback(
(existingConversationId?: number | null) => {
connect();
// Wait for connection then send start message
const checkAndSend = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
setIsLoading(true)
const payload: { type: string; conversation_id?: number } = { type: 'start' }
if (existingConversationId) {
payload.conversation_id = existingConversationId
setConversationId(existingConversationId)
// Wait for connection then send start message
const checkAndSend = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
setIsLoading(true);
const payload: { type: string; conversation_id?: number } = {
type: "start",
};
if (existingConversationId) {
payload.conversation_id = existingConversationId;
setConversationId(existingConversationId);
}
wsRef.current.send(JSON.stringify(payload));
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
setTimeout(checkAndSend, 100);
}
wsRef.current.send(JSON.stringify(payload))
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
setTimeout(checkAndSend, 100)
};
setTimeout(checkAndSend, 100);
},
[connect],
);
const sendMessage = useCallback(
(content: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.("Not connected");
return;
}
}
setTimeout(checkAndSend, 100)
}, [connect])
// Add user message to chat
setMessages((prev) => [
...prev,
{
id: generateId(),
role: "user",
content,
timestamp: new Date(),
},
]);
const sendMessage = useCallback((content: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected')
return
}
setIsLoading(true);
// Add user message to chat
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'user',
content,
timestamp: new Date(),
},
])
setIsLoading(true)
// Send to server
wsRef.current.send(
JSON.stringify({
type: 'message',
content,
})
)
}, [onError])
// Send to server
wsRef.current.send(
JSON.stringify({
type: "message",
content,
}),
);
},
[onError],
);
const disconnect = useCallback(() => {
reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
wsRef.current.close();
wsRef.current = null;
}
setConnectionStatus('disconnected')
}, [])
setConnectionStatus("disconnected");
}, []);
const clearMessages = useCallback(() => {
setMessages([])
setConversationId(null)
}, [])
setMessages([]);
setConversationId(null);
}, []);
return {
messages,
@@ -282,5 +333,5 @@ export function useAssistantChat({
sendMessage,
disconnect,
clearMessages,
}
};
}