opencode/packages/mobile-voice/src/hooks/use-server-sessions.ts
Ryan Vogel abf79ae24c refactor mobile screen orchestration
Extract server/session and monitoring workflows into focused hooks so DictationScreen no longer owns every network and notification path. Add a dedicated mobile typecheck config so TypeScript checks pass without breaking Expo export resolution.
2026-03-30 08:57:35 -04:00

386 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react"
import {
DEFAULT_RELAY_URL,
parseSessionItems,
persistServerState,
restoreServerState,
serverBases,
looksLikeLocalHost,
type ServerItem,
} from "@/lib/server-sessions"
export { DEFAULT_RELAY_URL, looksLikeLocalHost, type ServerItem, type SessionItem } from "@/lib/server-sessions"
export function useServerSessions() {
const [servers, setServers] = useState<ServerItem[]>([])
const [activeServerId, setActiveServerId] = useState<string | null>(null)
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const serversRef = useRef<ServerItem[]>([])
const restoredRef = useRef(false)
const refreshSeqRef = useRef<Record<string, number>>({})
const activeServerIdRef = useRef<string | null>(null)
const activeSessionIdRef = useRef<string | null>(null)
useEffect(() => {
serversRef.current = servers
}, [servers])
useEffect(() => {
activeServerIdRef.current = activeServerId
}, [activeServerId])
useEffect(() => {
activeSessionIdRef.current = activeSessionId
}, [activeSessionId])
useEffect(() => {
let mounted = true
void (async () => {
try {
const next = await restoreServerState()
if (!mounted || !next) return
setServers(next.servers)
setActiveServerId(next.activeServerId)
setActiveSessionId(next.activeSessionId)
console.log("[Server] restore", {
count: next.servers.length,
activeServerId: next.activeServerId,
})
} finally {
restoredRef.current = true
}
})()
return () => {
mounted = false
}
}, [])
useEffect(() => {
if (!restoredRef.current) return
void persistServerState(servers, activeServerId, activeSessionId).catch(() => {})
}, [activeServerId, activeSessionId, servers])
const refreshServerStatusAndSessions = useCallback(async (serverID: string, includeSessions = true) => {
const server = serversRef.current.find((item) => item.id === serverID)
if (!server) return
const req = (refreshSeqRef.current[serverID] ?? 0) + 1
refreshSeqRef.current[serverID] = req
const current = () => refreshSeqRef.current[serverID] === req
const candidates = serverBases(server.url)
const base = candidates[0] ?? server.url.replace(/\/+$/, "")
const healthURL = `${base}/health`
const sessionsURL = `${base}/experimental/session?limit=100`
let insecureRemote = false
try {
const parsedBase = new URL(base)
insecureRemote = parsedBase.protocol === "http:" && !looksLikeLocalHost(parsedBase.hostname)
} catch {
insecureRemote = base.startsWith("http://")
}
console.log("[Server] refresh:start", {
id: server.id,
name: server.name,
base,
healthURL,
sessionsURL,
includeSessions,
})
setServers((prev) =>
prev.map((item) => (item.id === serverID && includeSessions ? { ...item, sessionsLoading: true } : item)),
)
let activeBase = base
try {
let healthRes: Response | null = null
let healthErr: unknown
for (const item of candidates) {
const url = `${item}/health`
try {
const next = await fetch(url)
if (next.ok) {
healthRes = next
activeBase = item
if (item !== server.url.replace(/\/+$/, "") && current()) {
setServers((prev) => prev.map((entry) => (entry.id === serverID ? { ...entry, url: item } : entry)))
console.log("[Server] refresh:scheme-upgrade", {
id: server.id,
from: server.url,
to: item,
})
}
break
}
healthRes = next
activeBase = item
} catch (err) {
healthErr = err
console.log("[Server] health:attempt-error", {
id: server.id,
url,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
}
}
const online = !!healthRes?.ok
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
console.log("[Server] health", {
id: server.id,
base: activeBase,
url: `${activeBase}/health`,
status: healthRes?.status ?? "fetch_error",
online,
})
if (!online) {
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
),
)
console.log("[Server] refresh:offline", {
id: server.id,
base,
candidates,
error: healthErr instanceof Error ? `${healthErr.name}: ${healthErr.message}` : String(healthErr),
})
return
}
if (!includeSessions) {
setServers((prev) =>
prev.map((item) => (item.id === serverID ? { ...item, status: "online", sessionsLoading: false } : item)),
)
console.log("[Server] refresh:online", { id: server.id, base })
return
}
const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100`
const sessionsRes = await fetch(resolvedSessionsURL)
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
if (!sessionsRes.ok) {
console.log("[Server] sessions:http-error", {
id: server.id,
url: resolvedSessionsURL,
status: sessionsRes.status,
})
}
const json = sessionsRes.ok ? await sessionsRes.json() : []
const sessions = parseSessionItems(json)
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "online", sessionsLoading: false, sessions } : item,
),
)
console.log("[Server] sessions", { id: server.id, count: sessions.length })
} catch (err) {
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
),
)
console.log("[Server] refresh:error", {
id: server.id,
base,
healthURL,
sessionsURL,
candidates,
insecureRemote,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
if (insecureRemote) {
console.log("[Server] refresh:hint", {
id: server.id,
message: "Remote http:// host may be blocked by iOS ATS; prefer https:// for non-local hosts.",
})
}
}
}, [])
const refreshAllServerHealth = useCallback(() => {
const ids = serversRef.current.map((item) => item.id)
ids.forEach((id) => {
void refreshServerStatusAndSessions(id, false)
})
}, [refreshServerStatusAndSessions])
const selectServer = useCallback((id: string) => {
setActiveServerId(id)
setActiveSessionId(null)
}, [])
const selectSession = useCallback((id: string) => {
setActiveSessionId(id)
}, [])
const removeServer = useCallback((id: string) => {
setServers((prev) => prev.filter((item) => item.id !== id))
setActiveServerId((prev) => (prev === id ? null : prev))
if (activeServerIdRef.current === id) {
setActiveSessionId(null)
}
}, [])
const addServer = useCallback(
(serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
const raw = serverURL.trim()
if (!raw) return false
const normalized = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `http://${raw}`
const rawRelay = relayURL.trim()
const relayNormalizedRaw = rawRelay.length > 0 ? rawRelay : DEFAULT_RELAY_URL
const normalizedRelay =
relayNormalizedRaw.startsWith("http://") || relayNormalizedRaw.startsWith("https://")
? relayNormalizedRaw
: `http://${relayNormalizedRaw}`
let parsed: URL
let relayParsed: URL
try {
parsed = new URL(normalized)
relayParsed = new URL(normalizedRelay)
} catch {
return false
}
const id = `srv-${Date.now()}`
const relaySecret = relaySecretRaw.trim()
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const url = `${parsed.protocol}//${parsed.host}`
const inferredName =
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
const relay = `${relayParsed.protocol}//${relayParsed.host}`
const existing = serversRef.current.find(
(item) =>
item.url === url &&
item.relayURL === relay &&
item.relaySecret.trim() === relaySecret &&
(!serverID || item.serverID === serverID || item.serverID === null),
)
if (existing) {
if (serverID && existing.serverID !== serverID) {
setServers((prev) =>
prev.map((item) => (item.id === existing.id ? { ...item, serverID: serverID ?? item.serverID } : item)),
)
}
setActiveServerId(existing.id)
setActiveSessionId(null)
void refreshServerStatusAndSessions(existing.id)
return true
}
setServers((prev) => [
...prev,
{
id,
name: inferredName,
url,
serverID,
relayURL: relay,
relaySecret,
status: "offline",
sessions: [],
sessionsLoading: false,
},
])
setActiveServerId(id)
setActiveSessionId(null)
void refreshServerStatusAndSessions(id)
return true
},
[refreshServerStatusAndSessions],
)
const findServerForSession = useCallback(
async (sessionID: string, preferredServerID?: string | null): Promise<ServerItem | null> => {
if (!serversRef.current.length && !restoredRef.current) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150))
if (serversRef.current.length > 0 || restoredRef.current) {
break
}
}
}
if (preferredServerID) {
const preferred = serversRef.current.find((server) => server.serverID === preferredServerID)
if (preferred?.sessions.some((session) => session.id === sessionID)) {
return preferred
}
if (preferred) {
await refreshServerStatusAndSessions(preferred.id)
const refreshed = serversRef.current.find((server) => server.id === preferred.id)
if (refreshed?.sessions.some((session) => session.id === sessionID)) {
return refreshed
}
}
}
const direct = serversRef.current.find((server) => server.sessions.some((session) => session.id === sessionID))
if (direct) return direct
const ids = serversRef.current.map((server) => server.id)
for (const id of ids) {
await refreshServerStatusAndSessions(id)
const matched = serversRef.current.find(
(server) => server.id === id && server.sessions.some((session) => session.id === sessionID),
)
if (matched) {
return matched
}
}
return null
},
[refreshServerStatusAndSessions],
)
return {
servers,
setServers,
serversRef,
activeServerId,
setActiveServerId,
activeServerIdRef,
activeSessionId,
setActiveSessionId,
activeSessionIdRef,
restoredRef,
refreshServerStatusAndSessions,
refreshAllServerHealth,
selectServer,
selectSession,
removeServer,
addServer,
findServerForSession,
}
}