diff --git a/apps/mobile/app/(tabs)/user.tsx b/apps/mobile/app/(tabs)/user.tsx index ba7bcd1..39fad45 100644 --- a/apps/mobile/app/(tabs)/user.tsx +++ b/apps/mobile/app/(tabs)/user.tsx @@ -268,7 +268,7 @@ export default function UserPage() { Free · 1 tunnel - Upgrade for 2–5 parallel tunnels + Upgrade for 2 to 5 parallel tunnels + + ) + } return ( @@ -1005,7 +1101,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi ) - }, [refreshing, theme]) + }, [refreshing, loadError, theme, getAndSetMessages]) const handleAgentChange = useCallback((agent: string) => { setSelectedAgent(agent) @@ -1051,6 +1147,33 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi + {(!connection || loadError) && displayItems.length > 0 && ( + + + + + + {!connection ? "Not connected" : loadError?.title} + + + {!connection + ? "No usable tunnel for this project. Messages and sending are paused." + : loadError?.hint} + + + + + setLoadError(null)} hitSlop={8}> + + + + + + )} + {displayItems.length > 0 ? ( s.fetchAll) const [search, setSearch] = useState("") - const [statusFilter, setStatusFilter] = useState("active") + const [statusFilter, setStatusFilter] = useState("all") const [sort, setSort] = useState("name") const [filterOpen, setFilterOpen] = useState(false) const [sortOpen, setSortOpen] = useState(false) diff --git a/apps/mobile/app/sessions.tsx b/apps/mobile/app/sessions.tsx index 6b5ad43..652ce2a 100644 --- a/apps/mobile/app/sessions.tsx +++ b/apps/mobile/app/sessions.tsx @@ -255,7 +255,9 @@ export default function SessionsScreen() { }, [connection?.id, project?.id, fetchSessions]) const sessionSections = React.useMemo(() => { - let result = sessions.filter((s) => s.directory === project?.directory) + const dirOf = (s: Session) => + s.directory ?? (s as unknown as { location?: { directory?: string } }).location?.directory ?? "" + let result = sessions.filter((s) => (project?.directory ? dirOf(s) === project.directory : true)) if (searchQuery.trim()) { const q = searchQuery.toLowerCase() @@ -286,7 +288,9 @@ export default function SessionsScreen() { const visibleSessionCount = sessionSections.reduce((count, section) => count + section.data.length, 0) const categoryCounts = React.useMemo(() => { - const projectSessions = sessions.filter((s) => s.directory === project?.directory) + const dirOf = (s: Session) => + s.directory ?? (s as unknown as { location?: { directory?: string } }).location?.directory ?? "" + const projectSessions = sessions.filter((s) => (project?.directory ? dirOf(s) === project.directory : true)) return { all: projectSessions.length, agents: projectSessions.filter((s) => getSessionCategory(s) === "agents").length, diff --git a/apps/mobile/components/TunnelUsageCard.tsx b/apps/mobile/components/TunnelUsageCard.tsx index b7b9894..c7e3b3f 100644 --- a/apps/mobile/components/TunnelUsageCard.tsx +++ b/apps/mobile/components/TunnelUsageCard.tsx @@ -169,7 +169,7 @@ export function TunnelUsageCard() { > {stats.totalClients >= 1 - ? "At your Free limit — Pro gives 5 tunnels + hosted relay" + ? "At your Free limit. Pro gives 5 tunnels + hosted relay" : "Need more tunnels? Upgrade to Pro"} diff --git a/apps/mobile/components/chat-input.tsx b/apps/mobile/components/chat-input.tsx index dce42c8..cecd775 100644 --- a/apps/mobile/components/chat-input.tsx +++ b/apps/mobile/components/chat-input.tsx @@ -438,7 +438,7 @@ function ChatInputInner({ {isQueueMode ? ( - Queues after the current response — clear the input to stop instead. + Queues after the current response. Clear the input to stop instead. ) : null} diff --git a/apps/mobile/components/hooks/event-stream.ts b/apps/mobile/components/hooks/event-stream.ts index 9fd5560..c1a829c 100644 --- a/apps/mobile/components/hooks/event-stream.ts +++ b/apps/mobile/components/hooks/event-stream.ts @@ -182,7 +182,7 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, let pendingMessages: Message[] | null = null // Phone-local file:// uris salvaged from optimistic messages. Server echoes // user images back as data: URLs or server-local paths which often fail to - // render on device — replay the local uri instead, exactly like chat input. + // render on device. Replay the local uri instead, exactly like chat input. let localFileUriBackup: string[] = [] let flushScheduled = false const countedMessageIds = new Set() @@ -231,7 +231,7 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, // server-local paths), while the optimistic message holds // the renderable file:// uri. The old code merged first // (backup still empty, so server urls won) and only - // patched things up afterwards — leaving blank image boxes + // patched things up afterwards, leaving blank image boxes // whenever the patch-up missed. let salvagedLocalFileUrls: string[] = [] if (info.role === "user") { @@ -658,7 +658,7 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, // Only show reconnecting/connectivity-issues after repeated failures // to avoid banner flicker on brief, self-healing stream drops if (reconnectAttempts <= 2) { - // Keep "connected" — the stream may recover on its own + // Keep "connected". The stream may recover on its own } else if (reconnectAttempts <= 4) { setConnectionStatus("reconnecting") } else { diff --git a/apps/mobile/components/mention-autocomplete.tsx b/apps/mobile/components/mention-autocomplete.tsx index 7f3ac65..5059d8b 100644 --- a/apps/mobile/components/mention-autocomplete.tsx +++ b/apps/mobile/components/mention-autocomplete.tsx @@ -84,7 +84,7 @@ function MentionAutocompleteInner({ visible, loading, agents, files, onSelect, t ) : ( - No matches — keep typing or pick a subagent + No matches. Keep typing or pick a subagent ) } diff --git a/apps/mobile/components/message-item.tsx b/apps/mobile/components/message-item.tsx index 70a5d68..129e6f0 100644 --- a/apps/mobile/components/message-item.tsx +++ b/apps/mobile/components/message-item.tsx @@ -81,7 +81,7 @@ function getErrorLabel(name?: string): string { function normalizeImageUri(uri: string, mime?: string): string { if (!uri) return uri if (/^(data:|file:\/\/|content:\/\/|ph:\/\/|asset:\/\/|http:\/\/|https:\/\/)/.test(uri)) return uri - // Bare base64 payload (some servers strip the data: prefix) — rebuild it. + // Bare base64 payload (some servers strip the data: prefix). Rebuild it. if (mime?.startsWith("image/") && /^[A-Za-z0-9+/=\s]+$/.test(uri) && uri.length > 100) { return `data:${mime};base64,${uri.replace(/\s/g, "")}` } @@ -816,7 +816,7 @@ function MessageItemInner({ message, theme, projectId, sessionId, pendingQuestio - Queued — sends after current response + Queued: sends after current response diff --git a/apps/mobile/lib/diff.ts b/apps/mobile/lib/diff.ts index a9d2abe..6475a17 100644 --- a/apps/mobile/lib/diff.ts +++ b/apps/mobile/lib/diff.ts @@ -1,4 +1,5 @@ -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export type FileDiff = { file: string @@ -31,21 +32,37 @@ function normalizeFileDiff(value: unknown): FileDiff | null { export async function fetchSessionDiffs(url: string, token: string, sessionId: string, messageID?: string): Promise { try { + const version = getServerVersion(url) const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : "" - const res = await fetch(`${url}/session/${sessionId}/diff${query}`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token), - }, - }) - if (!res.ok) return [] - const data = await res.json() - const records = Array.isArray(data) - ? data - : data && typeof data === "object" && Array.isArray((data as Record).diffs) - ? (data as { diffs: unknown[] }).diffs - : [] - return records.map(normalizeFileDiff).filter((diff): diff is FileDiff => diff !== null) + // v2 moved diffs under the session revert/vcs surface; try legacy first. + const endpoints = [ + `${apiUrl(url, version, "/session")}/${sessionId}/diff${query}`, + `${apiUrl(url, version >= 2 ? 1 : 2, "/session")}/${sessionId}/diff${query}`, + ] + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token), + }, + }) + if (!res.ok) continue + const data = await res.json() + const records = unwrapList(data).length > 0 + ? unwrapList(data) + : Array.isArray(data) + ? data + : data && typeof data === "object" && Array.isArray((data as Record).diffs) + ? (data as { diffs: unknown[] }).diffs + : [] + const diffs = (records as unknown[]).map(normalizeFileDiff).filter((diff): diff is FileDiff => diff !== null) + if (diffs.length > 0) return diffs + } catch { + continue + } + } + return [] } catch { return [] } diff --git a/apps/mobile/lib/file-browser.ts b/apps/mobile/lib/file-browser.ts index 0a4fb43..92ba533 100644 --- a/apps/mobile/lib/file-browser.ts +++ b/apps/mobile/lib/file-browser.ts @@ -1,4 +1,5 @@ -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" import { normalizeLanguage } from "@/lib/prism" export type FileEntryType = "file" | "directory" @@ -17,7 +18,12 @@ const MAX_CONTENT_BYTES = 200 * 1024 function normalizeFileEntry(value: unknown): FileEntry | null { if (!value || typeof value !== "object") return null const entry = value as Record - const path = typeof entry.path === "string" ? entry.path : typeof entry.name === "string" ? entry.name : undefined + const path = + typeof entry.path === "string" + ? entry.path + : typeof entry.name === "string" + ? entry.name + : undefined if (!path) return null const rawType = typeof entry.type === "string" ? entry.type : "" const type: FileEntryType = rawType === "directory" ? "directory" : rawType === "file" ? "file" : path.endsWith("/") ? "directory" : "file" @@ -35,26 +41,65 @@ export function sortEntries(entries: FileEntry[]): FileEntry[] { } export async function listDirectory(url: string, token: string, path?: string): Promise { - try { - const query = `?path=${encodeURIComponent(path || ".")}` - const res = await fetch(`${url}/file${query}`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token), - }, - }) - if (!res.ok) throw new Error(`Failed to list directory (${res.status})`) - const data = await res.json() - if (!Array.isArray(data)) throw new Error("Unexpected directory listing response") - const entries = data.map(normalizeFileEntry).filter((entry): entry is FileEntry => entry !== null) - return sortEntries(entries) - } catch (err) { - throw err instanceof Error ? err : new Error("Failed to list directory") + const version = getServerVersion(url) + const dir = path || "." + // v2 canonical: GET /api/fs/list?path= ; v1: GET /file?path= + const endpoints = + version >= 2 + ? [`${apiUrl(url, 2, "/fs/list")}?path=${encodeURIComponent(dir)}`, `${apiUrl(url, 1, "/file")}?path=${encodeURIComponent(dir)}`] + : [`${apiUrl(url, 1, "/file")}?path=${encodeURIComponent(dir)}`, `${apiUrl(url, 2, "/fs/list")}?path=${encodeURIComponent(dir)}`] + let lastError: unknown = null + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token), + }, + }) + if (!res.ok) { + lastError = new Error(`Failed to list directory (${res.status})`) + continue + } + const data = await res.json() + const list = unwrapList(data) + if (!Array.isArray(list)) throw new Error("Unexpected directory listing response") + const entries = list.map(normalizeFileEntry).filter((entry): entry is FileEntry => entry !== null) + return sortEntries(entries) + } catch (err) { + lastError = err + } } + throw lastError instanceof Error ? lastError : new Error("Failed to list directory") } export async function readFileContent(url: string, token: string, path: string): Promise { - const res = await fetch(`${url}/file/content?path=${encodeURIComponent(path)}`, { + const version = getServerVersion(url) + // v2 serves raw bytes at /api/fs/read/*; legacy JSON at /file/content. + if (version >= 2) { + try { + const res = await fetch(`${apiUrl(url, 2, "/fs/read")}/${path.split("/").map(encodeURIComponent).join("/")}`, { + method: "GET", + headers: { "Authorization": getAuthHeader(token) }, + }) + if (res.ok) { + const text = await res.text() + // The raw endpoint may still return JSON { content } on some builds. + try { + const data = JSON.parse(text) as { content?: unknown; text?: unknown; data?: unknown } + if (typeof data.content === "string") return data.content.slice(0, MAX_CONTENT_BYTES) + if (typeof data.text === "string") return data.text.slice(0, MAX_CONTENT_BYTES) + if (typeof data.data === "string") return data.data.slice(0, MAX_CONTENT_BYTES) + } catch { + // Not JSON, treat as raw text. + } + return text.length > MAX_CONTENT_BYTES ? text.slice(0, MAX_CONTENT_BYTES) : text + } + } catch { + // Fall through to the legacy endpoint. + } + } + const res = await fetch(`${apiUrl(url, 1, "/file/content")}?path=${encodeURIComponent(path)}`, { method: "GET", headers: { "Authorization": getAuthHeader(token), @@ -75,50 +120,87 @@ export async function readFileContent(url: string, token: string, path: string): } export async function searchFiles(url: string, token: string, query: string): Promise { - try { - const res = await fetch(`${url}/find/file?query=${encodeURIComponent(query)}`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token), - }, - }) - if (!res.ok) return [] - const data = await res.json() - if (!Array.isArray(data)) return [] - return data.filter((item): item is string => typeof item === "string") - } catch { - return [] + const version = getServerVersion(url) + const endpoints = + version >= 2 + ? [`${apiUrl(url, 2, "/fs/find")}?query=${encodeURIComponent(query)}`, `${apiUrl(url, 1, "/find/file")}?query=${encodeURIComponent(query)}`] + : [`${apiUrl(url, 1, "/find/file")}?query=${encodeURIComponent(query)}`, `${apiUrl(url, 2, "/fs/find")}?query=${encodeURIComponent(query)}`] + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token), + }, + }) + if (!res.ok) continue + const data = await res.json() + const list = unwrapList(data) + if (!Array.isArray(list)) continue + // v2 returns Entry objects; v1 returns plain path strings. + const paths = list + .map((item) => + typeof item === "string" + ? item + : item && typeof item === "object" && typeof (item as Record).path === "string" + ? ((item as Record).path as string) + : null + ) + .filter((item): item is string => typeof item === "string") + if (paths.length > 0 || version >= 2) return paths + return paths + } catch { + continue + } } + return [] } export async function fetchFileStatuses(url: string, token: string): Promise { try { - const res = await fetch(`${url}/file/status`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token), - }, - }) - if (!res.ok) return {} - const data = await res.json() - if (!data || typeof data !== "object") return {} - - const records: Array<[string, unknown]> = Array.isArray(data) - ? data.filter((item) => item && typeof item === "object" && typeof (item as Record).path === "string") - .map((item) => [(item as Record).path as string, item]) - : Object.entries(data as Record) - - const result: FileStatuses = {} - for (const [key, value] of records) { - let status: unknown = value - if (value && typeof value === "object") { - status = (value as Record).status - } - if (status === "added" || status === "modified" || status === "deleted") { - result[key] = status + // v2 has no direct file-status route on some builds; try legacy first, + // then the v2 fs status alias when present. + const version = getServerVersion(url) + const endpoints = + version >= 2 + ? [apiUrl(url, 1, "/file/status"), apiUrl(url, 2, "/fs/status")] + : [apiUrl(url, 1, "/file/status")] + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token), + }, + }) + if (!res.ok) continue + const data = await res.json() + const unwrapped = unwrapList(data).length > 0 ? unwrapList(data) : data + if (!unwrapped || typeof unwrapped !== "object") continue + + const records: Array<[string, unknown]> = Array.isArray(unwrapped) + ? (unwrapped as unknown[]) + .filter((item) => item && typeof item === "object" && typeof (item as Record).path === "string") + .map((item) => [(item as Record).path as string, item]) + : Object.entries(unwrapped as Record) + + const result: FileStatuses = {} + for (const [key, value] of records) { + let status: unknown = value + if (value && typeof value === "object") { + const v = value as Record + status = v.status ?? v.state ?? v.kind + } + if (status === "added" || status === "modified" || status === "deleted") { + result[key] = status + } + } + return result + } catch { + continue } } - return result + return {} } catch { return {} } diff --git a/apps/mobile/lib/mentions.ts b/apps/mobile/lib/mentions.ts index 423db59..28ea632 100644 --- a/apps/mobile/lib/mentions.ts +++ b/apps/mobile/lib/mentions.ts @@ -1,4 +1,5 @@ -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export type MentionKind = "file" | "agent" @@ -66,17 +67,41 @@ export const fetchFileSuggestions = async ( opts?: { limit?: number; signal?: AbortSignal }, ): Promise => { try { + const version = getServerVersion(url) const params = new URLSearchParams({ query, limit: String(opts?.limit ?? 20), }) - const res = await fetch(`${url}/find/file?${params.toString()}`, { - headers: { Authorization: getAuthHeader(token) }, - signal: opts?.signal, - }) - if (!res.ok) return [] - const data = (await res.json()) as unknown - return Array.isArray(data) ? data.filter((p): p is string => typeof p === "string") : [] + const endpoints = + version >= 2 + ? [apiUrl(url, 2, "/fs/find"), apiUrl(url, 1, "/find/file")] + : [apiUrl(url, 1, "/find/file"), apiUrl(url, 2, "/fs/find")] + for (const base of endpoints) { + try { + const res = await fetch(`${base}?${params.toString()}`, { + headers: { Authorization: getAuthHeader(token) }, + signal: opts?.signal, + }) + if (!res.ok) continue + const data = (await res.json()) as unknown + const list = unwrapList(data) + const paths = (Array.isArray(list) ? list : []).map((p) => + typeof p === "string" + ? p + : p && typeof p === "object" && typeof (p as Record).path === "string" + ? ((p as Record).path as string) + : null + ).filter((p): p is string => typeof p === "string") + if (paths.length > 0) return paths + if (Array.isArray(data)) { + const bare = data.filter((p): p is string => typeof p === "string") + if (bare.length > 0) return bare + } + } catch { + continue + } + } + return [] } catch { return [] } @@ -98,14 +123,9 @@ export const fetchMentionFilePart = async ( path: string, ): Promise => { try { - const params = new URLSearchParams({ path }) - const res = await fetch(`${url}/file/content?${params.toString()}`, { - headers: { Authorization: getAuthHeader(token) }, - }) - if (!res.ok) return null - const data = (await res.json()) as { content?: unknown; text?: unknown } - const content = typeof data.content === "string" ? data.content : typeof data.text === "string" ? data.text : null - if (content === null || content.length === 0 || content.length > MAX_MENTION_BYTES) return null + const { readFileContent } = await import("@/lib/file-browser") + const content = await readFileContent(url, token, path) + if (!content || content.length === 0 || content.length > MAX_MENTION_BYTES) return null if (content.includes("\0")) return null return { mime: "text/plain", diff --git a/apps/mobile/lib/messages.ts b/apps/mobile/lib/messages.ts index e7a8000..c69f53f 100644 --- a/apps/mobile/lib/messages.ts +++ b/apps/mobile/lib/messages.ts @@ -1,21 +1,27 @@ import { Message } from "@/store/messages.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export const getMessages = async (url: string, token: string, sessionId: string, limit?: number, offset?: number) => { try { + const version = getServerVersion(url) + const base = apiUrl(url, version, `/session/${sessionId}/message`) const params = new URLSearchParams() if (limit) params.set("limit", limit.toString()) - if (offset) params.set("offset", offset.toString()) + // v2 paginates with opaque cursors (no numeric offset); offset is v1-only. + if (offset && version < 2) params.set("offset", offset.toString()) + if (version >= 2) params.set("order", "asc") const queryString = params.toString() - const res = await fetch(`${url}/session/${sessionId}/message${queryString ? `?${queryString}` : ""}`, { + const res = await fetch(`${base}${queryString ? `?${queryString}` : ""}`, { method: "GET", headers: { "Authorization": getAuthHeader(token) } }) if (!res.ok) return - const data = await res.json() as Message[] + const json = await res.json() + const data = unwrapList(json) if (!data) return return data } catch { diff --git a/apps/mobile/lib/models.ts b/apps/mobile/lib/models.ts index 37f25e1..a1794c5 100644 --- a/apps/mobile/lib/models.ts +++ b/apps/mobile/lib/models.ts @@ -197,6 +197,70 @@ async function fetchProviderCatalog(url: string, token: string): Promise<{ model } } +function normalizeV2Model(raw: Record, fallbackProviderID = ""): Model | null { + const id = + (typeof raw.modelID === "string" && raw.modelID) || + (typeof raw.id === "string" && raw.id) || + (typeof raw.apiID === "string" && raw.apiID) + if (!id) return null + const providerID = + (typeof raw.providerID === "string" && raw.providerID) || + (typeof raw.provider === "string" && raw.provider) || + fallbackProviderID + const name = + (typeof raw.name === "string" && raw.name) || id + // v2 uses `disabled: true` instead of status: "deprecated". + const disabled = raw.disabled === true + const status = disabled ? "deprecated" : normalizeStatus(typeof raw.status === "string" ? raw.status : "active") + const caps = (raw.capabilities ?? raw.capability ?? {}) as Record + const tools = + typeof caps.tools === "boolean" + ? caps.tools + : typeof (raw as Record).toolcall === "boolean" + ? ((raw as Record).toolcall as boolean) + : true + const input = Array.isArray(caps.input) ? (caps.input as string[]) : modalityList(caps.input as Record | undefined) + const output = Array.isArray(caps.output) ? (caps.output as string[]) : modalityList(caps.output as Record | undefined) + const limit = (raw.limit ?? {}) as Record + const costRaw = (raw.cost ?? {}) as Record + const cacheRaw = (costRaw.cache ?? {}) as Record + const variantsRaw = raw.variants + const variants = Array.isArray(variantsRaw) + ? variantsRaw + .map((v) => (typeof v === "string" ? { name: v } : v && typeof v === "object" && typeof (v as Record).id === "string" ? { name: (v as Record).id as string } : null)) + .filter((v): v is { name: string } => v !== null) + : variantsRaw && typeof variantsRaw === "object" + ? Object.keys(variantsRaw as Record).map((name) => ({ name })) + : [] + return { + id, + apiID: (typeof raw.apiID === "string" && raw.apiID) || id, + providerID, + family: (typeof raw.family === "string" && raw.family) || "", + name, + endpoint: { type: "unknown" }, + capabilities: { tools, input, output }, + status, + enabled: !disabled, + limit: { + context: typeof limit.context === "number" ? limit.context : 0, + input: typeof limit.input === "number" ? limit.input : null, + output: typeof limit.output === "number" ? limit.output : 0, + }, + cost: [{ + input: typeof costRaw.input === "number" ? costRaw.input : 0, + output: typeof costRaw.output === "number" ? costRaw.output : 0, + cache: { + read: typeof cacheRaw.read === "number" ? cacheRaw.read : 0, + write: typeof cacheRaw.write === "number" ? cacheRaw.write : 0, + }, + }], + variants, + options: { variant: null, ...((raw.options ?? raw.settings ?? {}) as Record) } as ModelOptions, + time: { released: 0 }, + } +} + async function fetchModelsV2(url: string, token: string): Promise { try { const res = await fetch(`${baseUrl(url)}/api/model`, { @@ -208,12 +272,21 @@ async function fetchModelsV2(url: string, token: string): Promise { return [] } const data = await res.json() - if (Array.isArray(data)) return data - if (data && typeof data === "object") { - if (Array.isArray(data.models)) return data.models - if (Array.isArray(data.data)) return data.data + let raw: unknown[] = [] + if (Array.isArray(data)) raw = data + else if (data && typeof data === "object") { + if (Array.isArray((data as Record).models)) raw = (data as Record).models as unknown[] + else if (Array.isArray((data as Record).data)) raw = (data as Record).data as unknown[] } - return [] + return raw + .map((item) => { + if (!item || typeof item !== "object") return null + // Already in app shape (has providerID + capabilities.tools)? + const r = item as Record + if (typeof r.id === "string" && typeof r.providerID === "string" && r.capabilities) return item as Model + return normalizeV2Model(r) + }) + .filter((m): m is Model => m !== null) } catch (error) { console.error("[fetchModels] Network error:", error) return [] @@ -228,9 +301,24 @@ async function fetchProvidersV2(url: string, token: string): Promise }) if (!res.ok) return [] const data = await res.json() - if (Array.isArray(data)) return data - if (data && typeof data === "object" && Array.isArray(data.data)) return data.data - return [] + let raw: unknown[] = [] + if (Array.isArray(data)) raw = data + else if (data && typeof data === "object" && Array.isArray((data as Record).data)) raw = (data as Record).data as unknown[] + return raw + .map((item) => { + if (!item || typeof item !== "object") return null + const r = item as Record + const id = typeof r.id === "string" ? r.id : typeof r.providerID === "string" ? (r.providerID as string) : null + if (!id) return null + return { + id, + name: (typeof r.name === "string" && r.name) || id, + enabled: r.disabled === true ? false : true, + env: Array.isArray(r.env) ? (r.env as string[]) : [], + endpoint: { type: "unknown" }, + } as Provider + }) + .filter((p): p is Provider => p !== null) } catch (error) { console.error("[fetchProviders] Network error:", error) return [] @@ -267,13 +355,21 @@ export async function updateSessionModel(url: string, token: string, sessionId: const version = getServerVersion(url) if (version >= 2) { // v2 sets the model via a dedicated endpoint (PATCH /session no longer accepts it). + // v2 Model.Ref is { providerID, modelID, variant? }. Send both + // `modelID` (canonical) and `id` (compat) so either shape works. + const ref: Record = { + providerID: model.providerID, + modelID: model.id, + id: model.id, + } + if (model.variant) ref.variant = model.variant await fetch(`${apiUrl(url, version, "/session")}/${sessionId}/model`, { method: "POST", headers: { "Authorization": getAuthHeader(token), "Content-Type": "application/json" }, - body: JSON.stringify({ model }) + body: JSON.stringify({ model: ref }) }) return } diff --git a/apps/mobile/lib/paywall.ts b/apps/mobile/lib/paywall.ts index d9ad0cd..0c4e561 100644 --- a/apps/mobile/lib/paywall.ts +++ b/apps/mobile/lib/paywall.ts @@ -84,7 +84,7 @@ export function getPaywallCopy(trigger: PaywallTrigger): PaywallCopy { case "saved_connections_cap": return { title: "Run every machine you own", - description: "Pro unlocks unlimited saved connections — every machine, one phone.", + description: "Pro unlocks unlimited saved connections. Every machine, one phone.", } case "tunnel_limit": return { @@ -94,7 +94,7 @@ export function getPaywallCopy(trigger: PaywallTrigger): PaywallCopy { case "file_browser": return { title: "Browse the whole repo from your phone", - description: "The file browser and viewer are Pro — explore any repo, anywhere.", + description: "The file browser and viewer are Pro. Explore any repo, anywhere.", } case "approval_rules": return { @@ -104,12 +104,12 @@ export function getPaywallCopy(trigger: PaywallTrigger): PaywallCopy { case "stable_url": return { title: "Stop re-scanning the QR", - description: "Pro's hosted relay gives you a stable URL — connect once, stay connected.", + description: "Pro's hosted relay gives you a stable URL. Connect once, stay connected.", } case "global_search": return { title: "Find that fix from last week", - description: "Pro searches across every session at once — never lose work again.", + description: "Pro searches across every session at once, so you never lose work.", } case "session_nudge": return { diff --git a/apps/mobile/lib/permissions.ts b/apps/mobile/lib/permissions.ts index e5e2f0b..e51a5f2 100644 --- a/apps/mobile/lib/permissions.ts +++ b/apps/mobile/lib/permissions.ts @@ -1,5 +1,6 @@ import { PermissionRequest } from "@/store/permissions.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" type RawPermission = Record @@ -96,8 +97,8 @@ export const getPendingPermissions = async ( _token: string, _sessionId?: string ): Promise => { - // OpenCode exposes no GET /permission list endpoint (see /doc spec) — - // pending permissions arrive via the `permission.updated` SSE event. + // OpenCode exposes no GET /permission list endpoint (see /doc spec). + // Pending permissions arrive via the `permission.updated` SSE event. // Keep this stub so callers don't hit a 404 polling loop. return [] } @@ -114,15 +115,27 @@ export const replyToPermission = async ( // body: { response: "once" | "always" | "reject" } if (!sessionId) return false try { - const res = await fetch(`${url}/session/${sessionId}/permissions/${requestId}`, { - method: "POST", - headers: { - Authorization: getAuthHeader(token), - "Content-Type": "application/json", - }, - body: JSON.stringify({ response: reply }), - }) - return res.ok + const version = getServerVersion(url) + const endpoints = [ + `${apiUrl(url, version, "/session")}/${sessionId}/permissions/${requestId}`, + `${apiUrl(url, version >= 2 ? 1 : 2, "/session")}/${sessionId}/permissions/${requestId}`, + ] + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: getAuthHeader(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ response: reply }), + }) + if (res.ok) return true + } catch { + continue + } + } + return false } catch { return false } diff --git a/apps/mobile/lib/project-connection.ts b/apps/mobile/lib/project-connection.ts index 8a88cba..e870aeb 100644 --- a/apps/mobile/lib/project-connection.ts +++ b/apps/mobile/lib/project-connection.ts @@ -5,9 +5,17 @@ import { useProjects } from "@/store/projects.store" * Resolve the connection that owns a project. * * Multi-connection: every project records `connectionId`, so screens under - * `/project/[projectId]/...` must use the owning connection — not a global + * `/project/[projectId]/...` must use the owning connection, not a global * "current"/selected connection. Falls back to the legacy `current` id for * deep links / screens that don't know their project yet. + * + * Robustness: if the stored project points at a deleted connection, or the + * project itself hasn't synced yet (empty store on a fresh install / deep + * link), fall back to `current` and then to the sole available connection so + * screens never render "Not connected" while a usable connection exists. + * Sessions lists are persisted (stale cache can look "loaded"), but + * messages/models/files need a live connection, so this fallback is what + * un-breaks them. */ export function useProjectConnection(projectId?: string | null) { const connections = useConnections((s) => s.connections) @@ -15,7 +23,18 @@ export function useProjectConnection(projectId?: string | null) { const project = useProjects((s) => projectId ? (s.projects.find((p) => p.id === projectId) ?? null) : null ) - const connection = + let connection = connections.find((c) => c.id === (project?.connectionId ?? current)) ?? null + if (!connection && connections.length === 1) { + connection = connections[0] ?? null + } + if (!connection && current) { + connection = connections.find((c) => c.id === current) ?? null + } + // Last resort: any connection with credentials (e.g. project sync hasn't + // run yet but the user has exactly one usable tunnel). + if (!connection) { + connection = connections.find((c) => c.url && c.token) ?? null + } return { project, connection } } diff --git a/apps/mobile/lib/projects.ts b/apps/mobile/lib/projects.ts index a56f5df..ab6858a 100644 --- a/apps/mobile/lib/projects.ts +++ b/apps/mobile/lib/projects.ts @@ -1,15 +1,38 @@ import { Project } from "@/store/projects.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapData, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export const getCurrentProject = async (url: string, token: string) => { try { + const version = getServerVersion(url) const headers = { "Authorization": getAuthHeader(token) } + // v2 exposes projects at /api/project (list) instead of + // /project/current + /path. Try v2 first when detected, fall back to v1. + if (version >= 2) { + const listRes = await fetch(apiUrl(url, version, "/project"), { method: "GET", headers }) + if (listRes.ok) { + const json = await listRes.json() + const list = unwrapList(json) + const first = list[0] ?? unwrapData(json) + if (first && typeof first === "object") { + const p = first as Record + const directory = + (p.directory as string | undefined) ?? + (p.worktree as string | undefined) ?? + ((p.location as Record | undefined)?.directory as string | undefined) + if (directory) return { ...(first as object), directory } as Project & { directory: string } + return first as Project + } + } + // Fall through to v1 paths below (bridged on some servers). + } + const [projectRes, pathRes] = await Promise.all([ - fetch(`${url}/project/current`, { method: "GET", headers }), - fetch(`${url}/path`, { method: "GET", headers }) + fetch(apiUrl(url, 1, "/project/current"), { method: "GET", headers }), + fetch(apiUrl(url, 1, "/path"), { method: "GET", headers }) ]) if (!projectRes.ok || !pathRes.ok) return @@ -19,8 +42,8 @@ export const getCurrentProject = async (url: string, token: string) => { if (!projectData) return - const project = projectData as Project - const directory = pathData?.directory as string | undefined + const project = (unwrapData(projectData) ?? projectData) as Project + const directory = (pathData as { directory?: string } | null)?.directory as string | undefined return { ...project, directory: directory ?? project.worktree } } catch { diff --git a/apps/mobile/lib/questions.ts b/apps/mobile/lib/questions.ts index 804bf41..81eda19 100644 --- a/apps/mobile/lib/questions.ts +++ b/apps/mobile/lib/questions.ts @@ -1,20 +1,35 @@ import { QuestionRequest } from "@/store/questions.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export const getPendingQuestions = async ( url: string, token: string ): Promise => { try { - const res = await fetch(`${url}/question`, { - method: "GET", - headers: { - Authorization: getAuthHeader(token), - }, - }) - if (!res.ok) return [] - const data = await res.json() - return Array.isArray(data) ? data : [] + const version = getServerVersion(url) + // v2: GET /api/question/request ; v1: GET /question + const endpoints = + version >= 2 + ? [apiUrl(url, 2, "/question/request"), apiUrl(url, 1, "/question")] + : [apiUrl(url, 1, "/question"), apiUrl(url, 2, "/question/request")] + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "GET", + headers: { + Authorization: getAuthHeader(token), + }, + }) + if (!res.ok) continue + const data = await res.json() + const list = unwrapList(data) + return Array.isArray(list) ? list : [] + } catch { + continue + } + } + return [] } catch { return [] } @@ -27,15 +42,30 @@ export const replyToQuestion = async ( answers: string[][] ): Promise => { try { - const res = await fetch(`${url}/question/${requestId}/reply`, { - method: "POST", - headers: { - Authorization: getAuthHeader(token), - "Content-Type": "application/json", - }, - body: JSON.stringify({ answers }), - }) - return res.ok + const version = getServerVersion(url) + // v2 replies are session-scoped; without a session we can't build the + // path, so fall back to the legacy route (bridged on some servers). + const endpoints = [ + apiUrl(url, 1, `/question/${requestId}/reply`), + apiUrl(url, 2, `/question/request/${requestId}/reply`), + ] + void version + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: getAuthHeader(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ answers }), + }) + if (res.ok) return true + } catch { + continue + } + } + return false } catch { return false } @@ -47,13 +77,24 @@ export const rejectQuestion = async ( requestId: string ): Promise => { try { - const res = await fetch(`${url}/question/${requestId}/reject`, { - method: "POST", - headers: { - Authorization: getAuthHeader(token), - }, - }) - return res.ok + const endpoints = [ + apiUrl(url, 1, `/question/${requestId}/reject`), + apiUrl(url, 2, `/question/request/${requestId}/reject`), + ] + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: getAuthHeader(token), + }, + }) + if (res.ok) return true + } catch { + continue + } + } + return false } catch { return false } diff --git a/apps/mobile/lib/recents.ts b/apps/mobile/lib/recents.ts index 24b81ea..c01b076 100644 --- a/apps/mobile/lib/recents.ts +++ b/apps/mobile/lib/recents.ts @@ -1,21 +1,35 @@ import { OpenCodeProject } from "@/store/recents.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export const getRecents = async (url: string, token: string) => { try { - const res = await fetch(`${url}/project`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token) - } - }) - if (!res.ok) return - const data = await res.json() as OpenCodeProject[] - if (!data) return + const version = getServerVersion(url) + const endpoints = + version >= 2 + ? [apiUrl(url, 2, "/project"), apiUrl(url, 1, "/project")] + : [apiUrl(url, 1, "/project"), apiUrl(url, 2, "/project")] + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token) + } + }) + if (!res.ok) continue + const json = await res.json() + const data = unwrapList(json) + if (!data || data.length === 0) continue - return data - .sort((a, b) => b.time.updated - a.time.updated) - .slice(0, 2) + return data + .sort((a, b) => b.time.updated - a.time.updated) + .slice(0, 2) + } catch { + continue + } + } + return } catch { return } diff --git a/apps/mobile/lib/sessions.ts b/apps/mobile/lib/sessions.ts index 32911e7..e7ca36f 100644 --- a/apps/mobile/lib/sessions.ts +++ b/apps/mobile/lib/sessions.ts @@ -1,34 +1,60 @@ import { Session } from "@/store/sessions.store" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapData, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" + +function sessionBase(url: string): string { + return apiUrl(url, getServerVersion(url), "/session") +} export const createSession = async (url: string, token: string, directory: string): Promise => { try { - const res = await fetch(`${url}/session`, { + const version = getServerVersion(url) + const body = version >= 2 + // v2 creates at a location; directory-only sessions use the location ref. + ? { location: { directory } } + : { directory } + const res = await fetch(sessionBase(url), { method: "POST", headers: { "Authorization": getAuthHeader(token), "Content-Type": "application/json", }, - body: JSON.stringify({ directory }), + body: JSON.stringify(body), }) if (!res.ok) return null - return await res.json() + const json = await res.json() + // v2 returns { data: Session }, v1 returns the session directly. + const raw = (unwrapData(json) ?? json) as Session & { location?: { directory?: string } } + return normalizeSession(raw, directory) } catch { return null } } +// v2 sessions carry `location: { directory }` instead of a top-level +// `directory`. Normalize so screens can keep filtering on `directory`. +function normalizeSession(raw: Session & { location?: { directory?: string } }, fallbackDir?: string): Session { + const directory = + (typeof raw.directory === "string" && raw.directory) || + (raw.location && typeof raw.location.directory === "string" && raw.location.directory) || + fallbackDir || + "" + return { ...raw, directory } as Session +} + export const getSessionsByProjectDir = async (url: string, token: string, dir: string) => { try { - const res = await fetch(`${url}/session?directory=${dir}`, { + const res = await fetch(`${sessionBase(url)}?directory=${encodeURIComponent(dir)}`, { method: "GET", headers: { "Authorization": getAuthHeader(token) } }) if (!res.ok) return - const data = await res.json() as Session[] - return data + const data = await res.json() + const list = unwrapList(data) + if (!list) return + return list.map((s) => normalizeSession(s, dir)) } catch { return } @@ -36,7 +62,7 @@ export const getSessionsByProjectDir = async (url: string, token: string, dir: s export const deleteSession = async (url: string, token: string, sessionId: string) => { try { - const res = await fetch(`${url}/session/${sessionId}`, { + const res = await fetch(`${sessionBase(url)}/${sessionId}`, { method: "DELETE", headers: { "Authorization": getAuthHeader(token) @@ -51,7 +77,9 @@ export const deleteSession = async (url: string, token: string, sessionId: strin export const shareSession = async (url: string, token: string, sessionId: string): Promise => { try { - const res = await fetch(`${url}/session/${sessionId}/share`, { + // Sharing was removed from the v2 API; the UI already gates it off there. + if (getServerVersion(url) >= 2) return null + const res = await fetch(`${sessionBase(url)}/${sessionId}/share`, { method: "POST", headers: { "Authorization": getAuthHeader(token), @@ -67,10 +95,13 @@ export const shareSession = async (url: string, token: string, sessionId: string export const revertMessage = async (url: string, token: string, sessionId: string, messageID: string, partID?: string): Promise => { try { - const res = await fetch(`${url}/session/${sessionId}/revert`, { + // v2 stages reverts via /revert/stage + /revert/commit; the legacy + // single-shot /revert only exists on v1. + if (getServerVersion(url) >= 2) return false + const res = await fetch(`${sessionBase(url)}/${sessionId}/revert`, { method: "POST", headers: { - "Authorization": `Basic ${btoa(`opencode:${token}`)}`, + "Authorization": getAuthHeader(token), "Content-Type": "application/json", }, body: JSON.stringify({ messageID, partID }), @@ -83,10 +114,11 @@ export const revertMessage = async (url: string, token: string, sessionId: strin export const forkSession = async (url: string, token: string, sessionId: string, messageID?: string): Promise => { try { - const res = await fetch(`${url}/session/${sessionId}/fork`, { + if (getServerVersion(url) >= 2) return null + const res = await fetch(`${sessionBase(url)}/${sessionId}/fork`, { method: "POST", headers: { - "Authorization": `Basic ${btoa(`opencode:${token}`)}`, + "Authorization": getAuthHeader(token), "Content-Type": "application/json", }, body: JSON.stringify({ messageID }), diff --git a/apps/mobile/lib/todo.ts b/apps/mobile/lib/todo.ts index a1e3287..2632fe9 100644 --- a/apps/mobile/lib/todo.ts +++ b/apps/mobile/lib/todo.ts @@ -1,4 +1,5 @@ -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export type TodoTask = { content: string @@ -8,7 +9,10 @@ export type TodoTask = { export async function fetchSessionTodos(url: string, token: string, sessionId: string): Promise { try { - const res = await fetch(`${url}/session/${sessionId}/todo`, { + const version = getServerVersion(url) + // Gated off on v2 (endpoint removed); callers already mute it there. + if (version >= 2) return [] + const res = await fetch(`${apiUrl(url, version, "/session")}/${sessionId}/todo`, { method: "GET", headers: { "Authorization": getAuthHeader(token), @@ -16,7 +20,8 @@ export async function fetchSessionTodos(url: string, token: string, sessionId: s }) if (!res.ok) return [] const data = await res.json() - return Array.isArray(data) ? data : [] + const list = unwrapList(data) + return Array.isArray(list) ? list : [] } catch { return [] } diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts index b46a182..99c0266 100644 --- a/apps/mobile/lib/utils.ts +++ b/apps/mobile/lib/utils.ts @@ -51,6 +51,26 @@ export function apiUrl(url: string, version: 1 | 2, path: string): string { return version >= 2 ? `${base}/api${clean}` : `${base}${clean}` } +// v2 wraps list payloads as { data: [...] } (sometimes { data: ..., cursor }). +// v1 returns the bare array. Accept both so one code path works on either server. +export function unwrapList(json: unknown): T[] { + if (Array.isArray(json)) return json as T[] + if (json && typeof json === "object") { + const obj = json as Record + if (Array.isArray(obj.data)) return obj.data as T[] + if (Array.isArray(obj.models)) return obj.models as T[] + } + return [] +} + +export function unwrapData(json: unknown): T | null { + if (json && typeof json === "object") { + const obj = json as Record + if ("data" in obj) return obj.data as T + } + return (json as T) ?? null +} + export function formatDirectory(path: string | undefined | null): string { if (!path) return "" const cleaned = path.replace(/\/+$/, '') diff --git a/apps/mobile/store/agents.store.ts b/apps/mobile/store/agents.store.ts index bc71a33..71d17f5 100644 --- a/apps/mobile/store/agents.store.ts +++ b/apps/mobile/store/agents.store.ts @@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" import { Agent } from "../lib/opencode" -import { getAuthHeader } from "@/lib/utils" +import { apiUrl, getAuthHeader, unwrapList } from "@/lib/utils" +import { getServerVersion } from "@/lib/server-version" export type AgentStore = { agents: Agent[] @@ -18,15 +19,29 @@ export const useAgents = create()( setAgents: (agents) => set({ agents }), fetchAgents: async (url, token) => { try { - const res = await fetch(`${url}/agent`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token) + const version = getServerVersion(url) + // v2 serves agents under /api/agent; v1 under /agent. + // Try the versioned path first, fall back to the other. + const paths = version >= 2 ? ["/agent", "/agent"] : ["/agent", "/agent"] + const tried = version >= 2 + ? [apiUrl(url, 2, "/agent"), apiUrl(url, 1, "/agent")] + : [apiUrl(url, 1, "/agent"), apiUrl(url, 2, "/agent")] + void paths + for (const endpoint of tried) { + const res = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token) + } + }) + if (!res.ok) continue + const json = await res.json() + const all: Agent[] = unwrapList(json) + if (all.length > 0) { + set({ agents: all.filter(a => a.mode === "primary" && !a.hidden) }) + return } - }) - if (!res.ok) return - const all: Agent[] = await res.json() - set({ agents: all.filter(a => a.mode === "primary" && !a.hidden) }) + } } catch (error) { console.error("[fetchAgents] Failed to fetch agents:", error) } diff --git a/apps/mobile/store/chat.store.ts b/apps/mobile/store/chat.store.ts index 89ce248..f7d34b9 100644 --- a/apps/mobile/store/chat.store.ts +++ b/apps/mobile/store/chat.store.ts @@ -120,7 +120,7 @@ export const useChatStore = create()( { name: "crosscode-chat", storage: createJSONStorage(() => AsyncStorage), - // Drafts change per keystroke — persisting them rewrites AsyncStorage + // Drafts change per keystroke, so persisting them rewrites AsyncStorage // on every char and causes I/O jank. Keep drafts memory-only. partialize: (state) => ({ modelByAgent: state.modelByAgent,