diff options
Diffstat (limited to 'makima/frontend/src/hooks')
| -rw-r--r-- | makima/frontend/src/hooks/useMeshChatHistory.ts | 133 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useTaskSubscription.ts | 333 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useTasks.ts | 130 |
3 files changed, 596 insertions, 0 deletions
diff --git a/makima/frontend/src/hooks/useMeshChatHistory.ts b/makima/frontend/src/hooks/useMeshChatHistory.ts new file mode 100644 index 0000000..82c576d --- /dev/null +++ b/makima/frontend/src/hooks/useMeshChatHistory.ts @@ -0,0 +1,133 @@ +import { useState, useCallback, useEffect } from "react"; +import { + getMeshChatHistory, + clearMeshChatHistory, + chatWithMeshContext, + type MeshChatMessageRecord, + type MeshChatContext, + type MeshChatResponse, + type LlmModel, +} from "../lib/api"; + +export interface MeshChatState { + conversationId: string | null; + messages: MeshChatMessageRecord[]; + loading: boolean; + error: string | null; + sending: boolean; +} + +export function useMeshChatHistory() { + const [state, setState] = useState<MeshChatState>({ + conversationId: null, + messages: [], + loading: true, + error: null, + sending: false, + }); + + const fetchHistory = useCallback(async () => { + setState((prev) => ({ ...prev, loading: true, error: null })); + try { + const response = await getMeshChatHistory(); + setState((prev) => ({ + ...prev, + conversationId: response.conversationId, + messages: response.messages, + loading: false, + })); + } catch (e) { + setState((prev) => ({ + ...prev, + error: e instanceof Error ? e.message : "Failed to fetch chat history", + loading: false, + })); + } + }, []); + + const clearHistory = useCallback(async (): Promise<boolean> => { + setState((prev) => ({ ...prev, loading: true, error: null })); + try { + const response = await clearMeshChatHistory(); + setState({ + conversationId: response.conversationId, + messages: [], + loading: false, + error: null, + sending: false, + }); + return true; + } catch (e) { + setState((prev) => ({ + ...prev, + error: e instanceof Error ? e.message : "Failed to clear chat history", + loading: false, + })); + return false; + } + }, []); + + const sendMessage = useCallback( + async ( + message: string, + context: MeshChatContext, + model?: LlmModel + ): Promise<MeshChatResponse | null> => { + setState((prev) => ({ ...prev, sending: true, error: null })); + + // Optimistically add user message (will be refetched after response) + const tempUserMessage: MeshChatMessageRecord = { + id: `temp-${Date.now()}`, + conversationId: state.conversationId || "", + role: "user", + content: message, + contextType: context.type, + contextTaskId: context.taskId || null, + toolCalls: null, + pendingQuestions: null, + createdAt: new Date().toISOString(), + }; + + setState((prev) => ({ + ...prev, + messages: [...prev.messages, tempUserMessage], + })); + + try { + const response = await chatWithMeshContext(message, context, model); + + // Refetch to get the actual saved messages (with proper IDs) + await fetchHistory(); + + setState((prev) => ({ ...prev, sending: false })); + return response; + } catch (e) { + // Remove optimistic message on error + setState((prev) => ({ + ...prev, + messages: prev.messages.filter((m) => m.id !== tempUserMessage.id), + error: e instanceof Error ? e.message : "Failed to send message", + sending: false, + })); + return null; + } + }, + [state.conversationId, fetchHistory] + ); + + // Initial fetch on mount + useEffect(() => { + fetchHistory(); + }, [fetchHistory]); + + return { + conversationId: state.conversationId, + messages: state.messages, + loading: state.loading, + error: state.error, + sending: state.sending, + fetchHistory, + clearHistory, + sendMessage, + }; +} diff --git a/makima/frontend/src/hooks/useTaskSubscription.ts b/makima/frontend/src/hooks/useTaskSubscription.ts new file mode 100644 index 0000000..9316c3a --- /dev/null +++ b/makima/frontend/src/hooks/useTaskSubscription.ts @@ -0,0 +1,333 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { TASK_SUBSCRIBE_ENDPOINT } from "../lib/api"; + +export interface TaskUpdateEvent { + taskId: string; + version: number; + status: string; + updatedFields: string[]; + updatedBy: "user" | "daemon" | "system"; +} + +export interface TaskOutputEvent { + taskId: string; + /** Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw" */ + messageType: string; + /** Main text content */ + content: string; + /** Tool name if tool_use message */ + toolName?: string; + /** Tool input JSON if tool_use message */ + toolInput?: Record<string, unknown>; + /** Whether tool result was an error */ + isError?: boolean; + /** Cost in USD if result message */ + costUsd?: number; + /** Duration in ms if result message */ + durationMs?: number; + isPartial: boolean; +} + +interface UseTaskSubscriptionOptions { + taskId: string | null; + subscribeAll?: boolean; + subscribeOutput?: boolean; + /** Task ID to subscribe output for (defaults to taskId if not specified) */ + outputTaskId?: string; + onUpdate?: (event: TaskUpdateEvent) => void; + onOutput?: (event: TaskOutputEvent) => void; + onError?: (error: string) => void; +} + +export function useTaskSubscription(options: UseTaskSubscriptionOptions) { + const { + taskId, + subscribeAll = false, + subscribeOutput = false, + outputTaskId, + onUpdate, + onOutput, + onError, + } = options; + + // The task ID to use for output subscription (defaults to taskId) + const effectiveOutputTaskId = outputTaskId || taskId; + + const [connected, setConnected] = useState(false); + const wsRef = useRef<WebSocket | null>(null); + const reconnectTimeoutRef = useRef<number | null>(null); + const subscribedTaskRef = useRef<string | null>(null); + const subscribedAllRef = useRef(false); + const subscribedOutputRef = useRef<string | null>(null); + + // Store callbacks in refs to avoid re-connecting when callbacks change + const callbacksRef = useRef({ onUpdate, onOutput, onError }); + useEffect(() => { + callbacksRef.current = { onUpdate, onOutput, onError }; + }, [onUpdate, onOutput, onError]); + + const connect = useCallback(() => { + // Prevent multiple connections - check for OPEN or CONNECTING states + const currentState = wsRef.current?.readyState; + if (currentState === WebSocket.OPEN || currentState === WebSocket.CONNECTING) { + return; + } + + // Close any existing connection that's in CLOSING state + if (wsRef.current && currentState === WebSocket.CLOSING) { + wsRef.current = null; + } + + try { + const ws = new WebSocket(TASK_SUBSCRIBE_ENDPOINT); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + // Re-subscribe if we had subscriptions + if (subscribedAllRef.current) { + ws.send(JSON.stringify({ type: "subscribeAll" })); + } + if (subscribedTaskRef.current) { + ws.send( + JSON.stringify({ + type: "subscribe", + taskId: subscribedTaskRef.current, + }) + ); + } + if (subscribedOutputRef.current) { + ws.send( + JSON.stringify({ + type: "subscribeOutput", + taskId: subscribedOutputRef.current, + }) + ); + } + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + + switch (message.type) { + case "taskUpdated": + callbacksRef.current.onUpdate?.({ + taskId: message.taskId, + version: message.version, + status: message.status, + updatedFields: message.updatedFields, + updatedBy: message.updatedBy, + }); + break; + case "taskOutput": + callbacksRef.current.onOutput?.({ + taskId: message.taskId, + messageType: message.messageType, + content: message.content, + toolName: message.toolName, + toolInput: message.toolInput, + isError: message.isError, + costUsd: message.costUsd, + durationMs: message.durationMs, + isPartial: message.isPartial, + }); + break; + case "error": + callbacksRef.current.onError?.(message.message); + break; + // Acknowledgement messages - could add callbacks if needed + case "subscribed": + case "unsubscribed": + case "subscribedAll": + case "unsubscribedAll": + case "outputSubscribed": + case "outputUnsubscribed": + break; + } + } catch (e) { + console.error("Failed to parse task subscription message:", e); + } + }; + + ws.onerror = () => { + callbacksRef.current.onError?.("WebSocket connection error"); + }; + + ws.onclose = () => { + setConnected(false); + wsRef.current = null; + + // Attempt reconnection after 3 seconds if we still have a subscription + if ( + subscribedTaskRef.current || + subscribedAllRef.current || + subscribedOutputRef.current + ) { + reconnectTimeoutRef.current = window.setTimeout(() => { + connect(); + }, 3000); + } + }; + } catch (e) { + callbacksRef.current.onError?.( + e instanceof Error ? e.message : "Failed to connect" + ); + } + }, []); + + const subscribeToTask = useCallback( + (id: string) => { + subscribedTaskRef.current = id; + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "subscribe", + taskId: id, + }) + ); + } else { + connect(); + } + }, + [connect] + ); + + const unsubscribeFromTask = useCallback(() => { + if ( + subscribedTaskRef.current && + wsRef.current?.readyState === WebSocket.OPEN + ) { + wsRef.current.send( + JSON.stringify({ + type: "unsubscribe", + taskId: subscribedTaskRef.current, + }) + ); + } + subscribedTaskRef.current = null; + }, []); + + const subscribeToAll = useCallback(() => { + subscribedAllRef.current = true; + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "subscribeAll" })); + } else { + connect(); + } + }, [connect]); + + const unsubscribeFromAll = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "unsubscribeAll" })); + } + subscribedAllRef.current = false; + }, []); + + const subscribeToOutput = useCallback( + (id: string) => { + // First unsubscribe from any previous output subscription + if (subscribedOutputRef.current && subscribedOutputRef.current !== id) { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "unsubscribeOutput", + taskId: subscribedOutputRef.current, + }) + ); + } + } + + subscribedOutputRef.current = id; + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "subscribeOutput", + taskId: id, + }) + ); + } else { + connect(); + } + }, + [connect] + ); + + const unsubscribeFromOutput = useCallback(() => { + if ( + subscribedOutputRef.current && + wsRef.current?.readyState === WebSocket.OPEN + ) { + wsRef.current.send( + JSON.stringify({ + type: "unsubscribeOutput", + taskId: subscribedOutputRef.current, + }) + ); + } + subscribedOutputRef.current = null; + }, []); + + // Auto-subscribe based on options + useEffect(() => { + if (subscribeAll) { + subscribeToAll(); + } else if (taskId) { + subscribeToTask(taskId); + } else { + unsubscribeFromTask(); + unsubscribeFromAll(); + } + + return () => { + unsubscribeFromTask(); + unsubscribeFromAll(); + }; + }, [ + taskId, + subscribeAll, + subscribeToTask, + unsubscribeFromTask, + subscribeToAll, + unsubscribeFromAll, + ]); + + // Handle output subscription separately + // Uses effectiveOutputTaskId which may be different from taskId when viewing subtask output + useEffect(() => { + if (subscribeOutput && effectiveOutputTaskId) { + subscribeToOutput(effectiveOutputTaskId); + } else { + unsubscribeFromOutput(); + } + + return () => { + unsubscribeFromOutput(); + }; + }, [effectiveOutputTaskId, subscribeOutput, subscribeToOutput, unsubscribeFromOutput]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, []); + + return { + connected, + subscribeToTask, + unsubscribeFromTask, + subscribeToAll, + unsubscribeFromAll, + subscribeToOutput, + unsubscribeFromOutput, + }; +} diff --git a/makima/frontend/src/hooks/useTasks.ts b/makima/frontend/src/hooks/useTasks.ts new file mode 100644 index 0000000..6e6c992 --- /dev/null +++ b/makima/frontend/src/hooks/useTasks.ts @@ -0,0 +1,130 @@ +import { useState, useCallback, useEffect } from "react"; +import { + listTasks, + getTask, + createTask, + updateTask, + deleteTask, + VersionConflictError, + type TaskSummary, + type TaskWithSubtasks, + type CreateTaskRequest, + type UpdateTaskRequest, +} from "../lib/api"; + +export interface ConflictState { + hasConflict: boolean; + expectedVersion: number; + actualVersion: number; +} + +export function useTasks() { + const [tasks, setTasks] = useState<TaskSummary[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [conflict, setConflict] = useState<ConflictState | null>(null); + + const fetchTasks = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await listTasks(); + setTasks(response.tasks); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch tasks"); + } finally { + setLoading(false); + } + }, []); + + const fetchTask = useCallback( + async (id: string): Promise<TaskWithSubtasks | null> => { + setError(null); + try { + return await getTask(id); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch task"); + return null; + } + }, + [] + ); + + const saveTask = useCallback( + async (data: CreateTaskRequest): Promise<TaskWithSubtasks | null> => { + setError(null); + try { + const task = await createTask(data); + await fetchTasks(); // Refresh list + // Return as TaskWithSubtasks + return { ...task, subtasks: [] }; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save task"); + return null; + } + }, + [fetchTasks] + ); + + const editTask = useCallback( + async (id: string, data: UpdateTaskRequest): Promise<TaskWithSubtasks | null> => { + setError(null); + setConflict(null); + try { + await updateTask(id, data); + await fetchTasks(); // Refresh list + // Re-fetch to get subtasks + return await getTask(id); + } catch (e) { + if (e instanceof VersionConflictError) { + setConflict({ + hasConflict: true, + expectedVersion: e.expectedVersion, + actualVersion: e.actualVersion, + }); + return null; + } + setError(e instanceof Error ? e.message : "Failed to update task"); + return null; + } + }, + [fetchTasks] + ); + + const clearConflict = useCallback(() => { + setConflict(null); + }, []); + + const removeTask = useCallback( + async (id: string): Promise<boolean> => { + setError(null); + try { + await deleteTask(id); + await fetchTasks(); // Refresh list + return true; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete task"); + return false; + } + }, + [fetchTasks] + ); + + // Initial fetch + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + return { + tasks, + loading, + error, + conflict, + clearConflict, + fetchTasks, + fetchTask, + saveTask, + editTask, + removeTask, + }; +} |
