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
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,