Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import { useAllowsMultipleConnections } from "@/lib/entitlement"
import { useAuth } from "@/store/auth.store"
import { usePaywall } from "@/store/paywall.store"
import { isAtTunnelLimit, requestPaywall } from "@/lib/paywall"
import { cn, formatDirectory, getAuthHeader } from "@/lib/utils"
import { cn, formatDirectory } from "@/lib/utils"
import { detectServerVersion } from "@/lib/server-version"
import { getCurrentProject } from "@/lib/projects"
import AlertTriangle from "lucide-react-native/dist/esm/icons/triangle-alert"
import ArrowUpDown from "lucide-react-native/dist/esm/icons/arrow-up-down"
Expand Down Expand Up @@ -287,13 +288,11 @@ export default function HomeScreen() {
await Promise.all(connections.map(async (conn) => {
if (!conn.url || !conn.token) return
try {
const res = await fetch(`${conn.url}/global/health`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(conn.token)
}
})
setConnectionHealth(conn.id, res.ok)
// Detecting the version doubles as the health check: a version
// means the server answered on a known API route. Always probe
// live here (already throttled above) so version and health stay fresh.
const version = await detectServerVersion(conn.url, conn.token, { force: true })
setConnectionHealth(conn.id, version !== null)
} catch {
setConnectionHealth(conn.id, false)
}
Expand Down
45 changes: 21 additions & 24 deletions apps/mobile/app/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { Text } from "@/components/ui/text"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import XIcon from "lucide-react-native/dist/esm/icons/x"
import { cn, getAuthHeader } from "@/lib/utils"
import { cn } from "@/lib/utils"
import { cacheServerVersion, detectServerVersion, type ServerVersion } from "@/lib/server-version"
import { THEME } from "@/lib/theme"
import { useColorScheme } from "nativewind"
import { consumePendingConnection, peekPendingConnection } from "@/lib/pending-connection"
Expand All @@ -28,6 +29,7 @@ export default function Connect() {
const [name, setName] = useState(`Connection ${new Date().toLocaleString()}`)
const [testing, setTesting] = useState<boolean>(false)
const [tested, setTested] = useState<{ msg: string, error: boolean } | null>(null)
const [serverVersion, setServerVersion] = useState<ServerVersion | null>(null)

const theme = colorScheme ?? "light"

Expand All @@ -37,29 +39,22 @@ export default function Connect() {
try {
validateConnectionUrl(url)
validateAuthToken(token)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 10000)
try {
const res = await fetch(`${url}/global/health`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token)
},
signal: controller.signal,
// Detect the opencode API major: v2 answers /api/info, v1 answers
// /global/health. Reachable either way means the server is up.
const version = await detectServerVersion(url, token, { force: true })
if (version) {
cacheServerVersion(url, version)
setServerVersion(version)
setTested({
msg: version >= 2 ? "Remote server reachable (opencode v2)" : "Remote server reachable",
error: false
})
} else {
setServerVersion(null)
setTested({
msg: "Remote server unreachable",
error: true
})
if (res.ok) {
setTested({
msg: "Remote server reachable",
error: false
})
} else {
setTested({
msg: "Remote server unreachable",
error: true
})
}
} finally {
clearTimeout(timer)
}
} catch {
setTested({
Expand Down Expand Up @@ -87,8 +82,10 @@ export default function Connect() {
addConnection({
url,
name,
token
token,
serverVersion: serverVersion ?? undefined,
})
cacheServerVersion(url, serverVersion ?? 1)
consumePendingConnection()
router.replace("/")
}
Expand Down
22 changes: 19 additions & 3 deletions apps/mobile/app/project/[projectId]/[sessionId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import XIcon from "lucide-react-native/dist/esm/icons/x"
import { useColorScheme } from "nativewind"
import { THEME } from "@/lib/theme"
import { Text } from "@/components/ui/text"
import { getMessages } from "@/lib/messages"
import { getMessages, sendPromptV2 } from "@/lib/messages"
import { Pressable } from "react-native"
import { Button } from "@/components/ui/button"
import { MessageItem } from "@/components/message-item"
Expand All @@ -34,7 +34,8 @@ import { usePermissions } from "@/store/permissions.store"
import { replyToPermission } from "@/lib/permissions"
import { PermissionRequest } from "@/store/permissions.store"
import { PermissionBlock } from "@/components/permission-block"
import { getAuthHeader } from "@/lib/utils"
import { getAuthHeader, apiUrl } from "@/lib/utils"
import { getServerVersion } from "@/lib/server-version"
import { MentionFilePart, SendMention, resolveSendMentions } from "@/lib/mentions"
import { notifyAgentStatus, setActiveChatScreen, clearActiveChatScreen } from "@/lib/notifications"

Expand Down Expand Up @@ -192,6 +193,8 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi

const pollQuestions = useCallback(async () => {
if (!connection?.url || !connection?.token) return
// No questions API on v2; the UI is muted there (see supportsFeature).
if (getServerVersion(connection.url) >= 2) return
if (appStateRef.current !== "active") {
// SSE is the primary channel; poll only as a 30s fallback so we
// don't duplicate events and waste radio/battery.
Expand Down Expand Up @@ -361,6 +364,15 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi
if (modelId && providerId) {
body.model = { modelID: modelId, providerID: providerId }
}
if (getServerVersion(connectionUrl) >= 2) {
return sendPromptV2(connectionUrl, connectionToken, targetSessionId, {
text,
agent: agentOverride ?? agent,
modelId,
providerId,
parts,
})
}
// delivery:"queue" tells opencode to run this as the next turn after
// the current response finishes, instead of steering the live turn.
if (delivery) {
Expand Down Expand Up @@ -523,7 +535,11 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi
const abortStreaming = useCallback(async () => {
if (!connection?.url || !isStreaming) return
try {
await fetch(`${connection.url}/session/${sessionId}/abort`, {
// v2 renamed abort to interrupt.
const endpoint = getServerVersion(connection.url) >= 2
? `${apiUrl(connection.url, 2, "/session")}/${sessionId}/interrupt`
: `${connection.url}/session/${sessionId}/abort`
await fetch(endpoint, {
method: "POST",
headers: { Authorization: getAuthHeader(connection.token) },
})
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { THEME } from "@/lib/theme"
import { cn } from "@/lib/utils"
import { useConnections } from "@/store/connection.store"
import { fetchSessionTodos, TodoTask } from "@/lib/todo"
import { getServerVersion, supportsFeature } from "@/lib/server-version"

const STATUS_CONFIG: Record<string, { icon: typeof CheckCircle2Icon; color: string; bg: string; label: string }> = {
completed: { icon: CheckCircle2Icon, color: "text-green-500", bg: "bg-green-500/15", label: "Completed" },
Expand Down Expand Up @@ -42,6 +43,8 @@ export default function TasksPage() {
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)

const todosSupported = supportsFeature(getServerVersion(connection?.url), "todo")

const loadTodos = useCallback(async () => {
if (!connection?.url || !connection?.token || !sessionId) return
const data = await fetchSessionTodos(connection.url, connection.token, sessionId)
Expand Down Expand Up @@ -168,10 +171,12 @@ export default function TasksPage() {
<CheckCircle2Icon size={28} color={THEME[theme].mutedForeground} />
</View>
<Text className="text-lg font-semibold tracking-tight text-center mb-2">
No tasks yet
{todosSupported ? "No tasks yet" : "Tasks need opencode v1"}
</Text>
<Text className="text-sm text-muted-foreground text-center leading-5">
Tasks will appear here when the agent creates a task list for this session.
{todosSupported
? "Tasks will appear here when the agent creates a task list for this session."
: "The v2 API does not expose session tasks yet. They will return automatically once supported."}
</Text>
</View>
) : (
Expand Down
33 changes: 18 additions & 15 deletions apps/mobile/components/hooks/event-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ type SSEEvent = {
properties: Record<string, unknown>
}

// v1 events look like { type, properties }; v2 wraps them as
// { id, type, data }. Accept both so either server version streams.
function normalizeEvent(parsed: unknown): SSEEvent | null {
if (!parsed || typeof parsed !== "object") return null
const raw = parsed as Record<string, unknown>
const inner = raw.payload && raw.type === undefined ? raw.payload : raw
if (!inner || typeof inner !== "object") return null
const event = inner as Record<string, unknown>
if (typeof event.type !== "string") return null
const properties = (event.data ?? event.properties ?? {}) as Record<string, unknown>
if (!properties || typeof properties !== "object") return null
return { type: event.type, properties }
}

type MessagePart = Part & {
sessionID?: string
messageID?: string
Expand Down Expand Up @@ -106,11 +120,8 @@ export function useGlobalSessionStatus(url?: string, token?: string) {
if (dataLines.length > 0) {
const data = dataLines.join("\n")
try {
const parsed = JSON.parse(data)
let sseEvent: SSEEvent
if (parsed.payload && parsed.type === undefined) sseEvent = parsed.payload
else sseEvent = parsed
handleEvent(sseEvent)
const sseEvent = normalizeEvent(JSON.parse(data))
if (sseEvent) handleEvent(sseEvent)
} catch {}
}
}
Expand Down Expand Up @@ -684,16 +695,8 @@ export function useEventStream(url?: string, sessionId?: string, token?: string,
const data = dataLines.join("\n")

try {
const parsed = JSON.parse(data)
let sseEvent: SSEEvent

if (parsed.payload && parsed.type === undefined) {
sseEvent = parsed.payload
} else {
sseEvent = parsed
}

handleEvent(sseEvent, sid)
const sseEvent = normalizeEvent(JSON.parse(data))
if (sseEvent) handleEvent(sseEvent, sid)
} catch {
// Parse error - skip malformed event
}
Expand Down
21 changes: 15 additions & 6 deletions apps/mobile/components/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"
import { THEME } from "@/lib/theme"
import { shareSession } from "@/lib/sessions"
import { getServerVersion, supportsFeature } from "@/lib/server-version"
import { useConnections } from "@/store/connection.store"
import { useSessions } from "@/store/sessions.store"
import { useSettings } from "@/store/settings.store"
Expand Down Expand Up @@ -56,6 +57,12 @@ function SessionHeaderInner({

const displayTitle = title && title.length > 40 ? title.slice(0, 37) + "..." : title

// share/tasks have no v2 API yet: keep the rows but mute them on v2
// so they light back up when the server supports them again.
const serverVersion = getServerVersion(connection?.url)
const shareSupported = supportsFeature(serverVersion, "share")
const tasksSupported = supportsFeature(serverVersion, "todo")

const toggleMenu = () => setShowMenu(!showMenu)
const closeMenu = () => setShowMenu(false)

Expand Down Expand Up @@ -120,11 +127,12 @@ function SessionHeaderInner({
>
<View className="py-2">
<Pressable
className="flex-row items-center gap-3 px-4 py-2.5 active:bg-accent/50"
onPress={() => handleNavigate(`/project/${projectId}/${sessionId}/tasks`)}
className={`flex-row items-center gap-3 px-4 py-2.5 active:bg-accent/50${tasksSupported ? "" : " opacity-40"}`}
disabled={!tasksSupported}
onPress={() => tasksSupported && handleNavigate(`/project/${projectId}/${sessionId}/tasks`)}
>
<ListTodoIcon size={16} color={THEME[theme].mutedForeground} />
<Text className="text-sm text-foreground">Tasks</Text>
<Text className="text-sm text-foreground">Tasks{tasksSupported ? "" : " (v2 soon)"}</Text>
</Pressable>

<Pressable
Expand Down Expand Up @@ -159,11 +167,12 @@ function SessionHeaderInner({

<View className="border-t border-border/50 py-2">
<Pressable
className="flex-row items-center gap-3 px-4 py-2.5 active:bg-accent/50"
onPress={handleShare}
className={`flex-row items-center gap-3 px-4 py-2.5 active:bg-accent/50${shareSupported ? "" : " opacity-40"}`}
disabled={!shareSupported}
onPress={() => shareSupported && handleShare()}
>
<ShareIcon size={16} color={THEME[theme].mutedForeground} />
<Text className="text-sm text-foreground">Share session</Text>
<Text className="text-sm text-foreground">Share session{shareSupported ? "" : " (v2 soon)"}</Text>
</Pressable>

<Pressable
Expand Down
25 changes: 17 additions & 8 deletions apps/mobile/lib/diff.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,20 +32,28 @@ function normalizeFileDiff(value: unknown): FileDiff | null {

export async function fetchSessionDiffs(url: string, token: string, sessionId: string, messageID?: string): Promise<FileDiff[]> {
try {
const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : ""
const res = await fetch(`${url}/session/${sessionId}/diff${query}`, {
const version = getServerVersion(url)
// v2 selects the turn with ?from= and wraps rows in { data }.
const query = messageID
? version >= 2
? `?from=${encodeURIComponent(messageID)}`
: `?messageID=${encodeURIComponent(messageID)}`
: ""
const res = await fetch(`${apiUrl(url, version, "/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<string, unknown>).diffs)
? (data as { diffs: unknown[] }).diffs
: []
const records = unwrapList<unknown>(data).length > 0
? unwrapList<unknown>(data)
: Array.isArray(data)
? data
: data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).diffs)
? (data as { diffs: unknown[] }).diffs
: []
return records.map(normalizeFileDiff).filter((diff): diff is FileDiff => diff !== null)
} catch {
return []
Expand Down
Loading
Loading