import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { buildBaseUrl, checkHealth, loadSavedComputers, loadSavedProjects, loadLastModel, removeSavedComputer, saveHostConfig, saveSavedProjects, saveLastModel, upsertSavedComputer, } from "@/lib/api"; import { parseConnectDeepLink } from "@/lib/deeplink"; import { createSocket, listModels as requestModels, setRuntimeModel as requestSetRuntimeModel, setRuntimeThinkingLevel as requestSetRuntimeThinkingLevel, } from "@/lib/socket"; import type { ChatEvent, ChatHistoryPayload, LiveRuntimeInfo, ModelListPayload, ModelRef, PersistedSessionInfo, RuntimeCreatedPayload, SavedComputer, ThinkingLevel, } from "@/lib/types"; import type { Socket } from "socket.io-client"; import * as Linking from "expo-linking"; type SocketContextValue = { host: string; port: string; computerName: string; token: string; baseUrl: string | null; connected: boolean; connecting: boolean; error: string | null; savedComputers: SavedComputer[]; persistedSessions: PersistedSessionInfo[]; liveRuntimes: LiveRuntimeInfo[]; savedProjects: string[]; addProject: (cwd: string) => Promise; removeProject: (cwd: string) => Promise; setHost: (host: string) => void; setPort: (port: string) => void; setComputerName: (name: string) => void; setToken: (token: string) => void; connect: () => Promise; connectToSavedComputer: (computer: SavedComputer) => Promise; connectFromDeepLink: (url: string) => Promise; removeSavedComputerEntry: (id: string) => Promise; disconnect: () => void; refreshSessions: () => void; createRuntime: ( cwd: string, name?: string, model?: ModelRef, thinkingLevel?: ThinkingLevel, ) => void; lastModel: ModelRef | null; listModels: (payload: { runtimeId?: string; cwd?: string }) => Promise; setRuntimeModel: (runtimeId: string, model: ModelRef) => Promise; setRuntimeThinkingLevel: (runtimeId: string, level: ThinkingLevel) => Promise; openRuntime: (sessionFile: string) => void; attachRuntime: (runtimeId: string) => void; sendPrompt: (runtimeId: string, message: string) => void; abortPrompt: (runtimeId: string) => void; subscribeChat: (listener: (event: ChatEvent) => void) => () => void; subscribeChatHistory: (listener: (payload: ChatHistoryPayload) => void) => () => void; waitForRuntime: ( kind: "created" | "opened", timeoutMs?: number, ) => Promise; }; const SocketContext = createContext(null); export function SocketProvider({ children }: { children: ReactNode }) { const [host, setHost] = useState(""); const [port, setPort] = useState("8787"); const [computerName, setComputerName] = useState(""); const [token, setToken] = useState(""); const [baseUrl, setBaseUrl] = useState(null); const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(null); const [savedComputers, setSavedComputers] = useState([]); const [persistedSessions, setPersistedSessions] = useState([]); const [liveRuntimes, setLiveRuntimes] = useState([]); const [savedProjects, setSavedProjects] = useState([]); const [lastModel, setLastModel] = useState(null); const socketRef = useRef(null); const chatListenersRef = useRef(new Set<(event: ChatEvent) => void>()); const chatHistoryListenersRef = useRef(new Set<(payload: ChatHistoryPayload) => void>()); const runtimeWaitersRef = useRef<{ created: Array<(payload: RuntimeCreatedPayload) => void>; opened: Array<(payload: RuntimeCreatedPayload) => void>; }>({ created: [], opened: [] }); useEffect(() => { void loadLastModel().then(setLastModel); void loadSavedComputers().then((computers) => { setSavedComputers(computers); const recent = computers[0]; if (!recent) { return; } setHost(recent.host); setPort(recent.port); setComputerName(recent.name); setToken(recent.token ?? ""); void loadSavedProjects(recent.host, recent.port).then(setSavedProjects); }); }, []); const removeSavedComputerEntry = useCallback( async (id: string) => { const next = await removeSavedComputer(savedComputers, id); setSavedComputers(next); }, [savedComputers], ); const addProject = useCallback( async (cwd: string) => { const trimmed = cwd.trim(); if (!trimmed) { return; } setSavedProjects((current) => { if (current.some((entry) => entry === trimmed)) { return current; } const next = [...current, trimmed]; void saveSavedProjects(host, port, next); return next; }); }, [host, port], ); const removeProject = useCallback( async (cwd: string) => { setSavedProjects((current) => { const next = current.filter((entry) => entry !== cwd); void saveSavedProjects(host, port, next); return next; }); }, [host, port], ); const disconnect = useCallback(() => { socketRef.current?.disconnect(); socketRef.current = null; setConnected(false); setBaseUrl(null); }, []); const connectWithHost = useCallback( async ( nextHost: string, nextPort: string, nextName?: string, nextToken?: string, ) => { setConnecting(true); setError(null); try { const nextBaseUrl = buildBaseUrl(nextHost, nextPort); const trimmedToken = nextToken?.trim(); const healthy = await checkHealth(nextBaseUrl, trimmedToken); if (!healthy) { throw new Error("Could not reach Pi Mobile API"); } disconnect(); const socket = createSocket( nextBaseUrl, { onConnect: () => { setConnected(true); setConnecting(false); socket.emit("sessions:list"); }, onDisconnect: () => { setConnected(false); }, onError: (message) => setError(message), onSessionsList: (payload) => { setPersistedSessions(payload.persisted); setLiveRuntimes(payload.live); }, onRuntimeCreated: (payload) => { for (const resolve of runtimeWaitersRef.current.created.splice(0)) { resolve(payload); } }, onRuntimeOpened: (payload) => { for (const resolve of runtimeWaitersRef.current.opened.splice(0)) { resolve(payload); } }, onChatEvent: (payload) => { for (const listener of chatListenersRef.current) { listener(payload); } }, onChatHistory: (payload) => { for (const listener of chatHistoryListenersRef.current) { listener(payload); } }, }, trimmedToken, ); socketRef.current = socket; setBaseUrl(nextBaseUrl); setHost(nextHost); setPort(nextPort); if (nextName !== undefined) { setComputerName(nextName); } if (nextToken !== undefined) { setToken(nextToken); } await saveHostConfig({ host: nextHost, port: nextPort, token: trimmedToken }); const nextComputers = await upsertSavedComputer( savedComputers, nextHost, nextPort, nextName ?? computerName, trimmedToken, ); setSavedComputers(nextComputers); const projects = await loadSavedProjects(nextHost, nextPort); setSavedProjects(projects); return true; } catch (connectError) { const message = connectError instanceof Error ? connectError.message : "Failed to connect"; setError(message); setConnecting(false); return false; } }, [computerName, disconnect, savedComputers], ); const connect = useCallback(async () => { return connectWithHost(host, port, computerName, token); }, [connectWithHost, host, port, computerName, token]); const connectToSavedComputer = useCallback( async (computer: SavedComputer) => { return connectWithHost( computer.host, computer.port, computer.name, computer.token, ); }, [connectWithHost], ); const connectFromDeepLink = useCallback( async (url: string) => { const params = parseConnectDeepLink(url); if (!params) { return false; } return connectWithHost( params.host, params.port, params.name ?? params.host, params.token, ); }, [connectWithHost], ); useEffect(() => { const handleDeepLink = (url: string) => { void connectFromDeepLink(url); }; void Linking.getInitialURL().then((url) => { if (url) { handleDeepLink(url); } }); const subscription = Linking.addEventListener("url", (event) => { handleDeepLink(event.url); }); return () => { subscription.remove(); }; }, [connectFromDeepLink]); const refreshSessions = useCallback(() => { socketRef.current?.emit("sessions:list"); }, []); const createRuntime = useCallback(( cwd: string, name?: string, model?: ModelRef, thinkingLevel?: ThinkingLevel, ) => { socketRef.current?.emit("runtime:create", { cwd, name, model, thinkingLevel }); }, []); const listModels = useCallback( async (payload: { runtimeId?: string; cwd?: string }) => { const socket = socketRef.current; if (!socket) { throw new Error("Not connected"); } try { return await requestModels(socket, payload); } catch (requestError) { const message = requestError instanceof Error ? requestError.message : "Failed to load models"; setError(message); throw requestError; } }, [], ); const setRuntimeModel = useCallback(async (runtimeId: string, model: ModelRef) => { const socket = socketRef.current; if (!socket) { throw new Error("Not connected"); } setLastModel(model); try { const runtime = await requestSetRuntimeModel(socket, runtimeId, model); await saveLastModel(model); setLiveRuntimes((current) => current.map((entry) => (entry.runtimeId === runtime.runtimeId ? runtime : entry)), ); return runtime; } catch (requestError) { const message = requestError instanceof Error ? requestError.message : "Failed to set model"; setError(message); throw requestError; } }, []); const setRuntimeThinkingLevel = useCallback( async (runtimeId: string, level: ThinkingLevel) => { const socket = socketRef.current; if (!socket) { throw new Error("Not connected"); } try { const runtime = await requestSetRuntimeThinkingLevel(socket, runtimeId, level); setLiveRuntimes((current) => current.map((entry) => (entry.runtimeId === runtime.runtimeId ? runtime : entry)), ); return runtime; } catch (requestError) { const message = requestError instanceof Error ? requestError.message : "Failed to set reasoning effort"; setError(message); throw requestError; } }, [], ); const openRuntime = useCallback((sessionFile: string) => { socketRef.current?.emit("runtime:open", { sessionFile }); }, []); const attachRuntime = useCallback((runtimeId: string) => { socketRef.current?.emit("runtime:attach", { runtimeId }); }, []); const sendPrompt = useCallback((runtimeId: string, message: string) => { socketRef.current?.emit("chat:prompt", { runtimeId, message }); }, []); const abortPrompt = useCallback((runtimeId: string) => { socketRef.current?.emit("chat:abort", { runtimeId }); }, []); const subscribeChat = useCallback((listener: (event: ChatEvent) => void) => { chatListenersRef.current.add(listener); return () => { chatListenersRef.current.delete(listener); }; }, []); const subscribeChatHistory = useCallback((listener: (payload: ChatHistoryPayload) => void) => { chatHistoryListenersRef.current.add(listener); return () => { chatHistoryListenersRef.current.delete(listener); }; }, []); const waitForRuntime = useCallback( (kind: "created" | "opened", timeoutMs = 15000) => new Promise((resolve, reject) => { const timer = setTimeout(() => { runtimeWaitersRef.current[kind] = runtimeWaitersRef.current[kind].filter( (entry) => entry !== resolve, ); reject(new Error("Timed out waiting for runtime")); }, timeoutMs); const wrappedResolve = (payload: RuntimeCreatedPayload) => { clearTimeout(timer); resolve(payload); }; runtimeWaitersRef.current[kind].push(wrappedResolve); }), [], ); const value = useMemo( () => ({ host, port, computerName, token, baseUrl, connected, connecting, error, savedComputers, persistedSessions, liveRuntimes, savedProjects, lastModel, addProject, removeProject, setHost, setPort, setComputerName, setToken, connect, connectToSavedComputer, connectFromDeepLink, removeSavedComputerEntry, disconnect, refreshSessions, createRuntime, listModels, setRuntimeModel, setRuntimeThinkingLevel, openRuntime, attachRuntime, sendPrompt, abortPrompt, subscribeChat, subscribeChatHistory, waitForRuntime, }), [ host, port, computerName, token, baseUrl, connected, connecting, error, savedComputers, persistedSessions, liveRuntimes, savedProjects, lastModel, addProject, removeProject, connect, connectToSavedComputer, connectFromDeepLink, removeSavedComputerEntry, disconnect, refreshSessions, createRuntime, listModels, setRuntimeModel, setRuntimeThinkingLevel, openRuntime, attachRuntime, sendPrompt, abortPrompt, subscribeChat, subscribeChatHistory, waitForRuntime, ], ); return {children}; } export function useSocket() { const context = useContext(SocketContext); if (!context) { throw new Error("useSocket must be used within SocketProvider"); } return context; }