Initial commit: OpenCode mobile app (extracted from opencode monorepo)
This commit is contained in:
174
src/lib/notifications.ts
Normal file
174
src/lib/notifications.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import * as Notifications from "expo-notifications"
|
||||
import * as Device from "expo-device"
|
||||
import { Platform, AppState } from "react-native"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categories — every notification belongs to exactly one
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const categories = ["permissions", "questions", "completed", "errors", "connection"] as const
|
||||
export type Category = (typeof categories)[number]
|
||||
|
||||
// Per-category metadata used by both the notification module and the settings UI
|
||||
export const categoryMeta: Record<Category, { label: string; description: string; icon: string }> = {
|
||||
permissions: {
|
||||
label: "Permission Requests",
|
||||
description: "When a tool needs approval to proceed",
|
||||
icon: "shield-checkmark",
|
||||
},
|
||||
questions: {
|
||||
label: "Questions",
|
||||
description: "When the assistant needs your input",
|
||||
icon: "help-circle",
|
||||
},
|
||||
completed: {
|
||||
label: "Task Completed",
|
||||
description: "When a session finishes processing",
|
||||
icon: "checkmark-circle",
|
||||
},
|
||||
errors: {
|
||||
label: "Errors",
|
||||
description: "When a session encounters a problem",
|
||||
icon: "alert-circle",
|
||||
},
|
||||
connection: {
|
||||
label: "Connection",
|
||||
description: "When the app loses connection for an extended period",
|
||||
icon: "wifi",
|
||||
},
|
||||
}
|
||||
|
||||
// Default state — permissions and questions on (they block work), the rest off
|
||||
export const defaultPreferences: Record<Category, boolean> = {
|
||||
permissions: true,
|
||||
questions: true,
|
||||
completed: false,
|
||||
errors: true,
|
||||
connection: true,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification payload — what callers pass in
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Payload {
|
||||
category: Category
|
||||
title: string
|
||||
body: string
|
||||
sessionId: string
|
||||
dedupeKey?: string
|
||||
dedupeCooldownMs?: number
|
||||
}
|
||||
|
||||
// Data embedded in the notification for tap handling
|
||||
interface NotificationData {
|
||||
category: Category
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preferences accessor — injected to avoid circular imports with the store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PreferencesAccessor = () => Record<Category, boolean>
|
||||
|
||||
let preferences: PreferencesAccessor = () => defaultPreferences
|
||||
const dedupeSentAt = new Map<string, number>()
|
||||
|
||||
export function configure(accessor: PreferencesAccessor) {
|
||||
preferences = accessor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup & permissions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
})
|
||||
|
||||
export async function setup(): Promise<boolean> {
|
||||
if (!Device.isDevice) return false
|
||||
|
||||
const { status: existing } = await Notifications.getPermissionsAsync()
|
||||
if (existing === "granted") {
|
||||
await ensureChannel()
|
||||
return true
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
if (status !== "granted") return false
|
||||
|
||||
await ensureChannel()
|
||||
return true
|
||||
}
|
||||
|
||||
export async function granted(): Promise<boolean> {
|
||||
const { status } = await Notifications.getPermissionsAsync()
|
||||
return status === "granted"
|
||||
}
|
||||
|
||||
async function ensureChannel() {
|
||||
if (Platform.OS !== "android") return
|
||||
await Notifications.setNotificationChannelAsync("prompts", {
|
||||
name: "Session Prompts & Updates",
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
sound: "default",
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send — single entry point, decides whether to fire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function send(payload: Payload) {
|
||||
const prefs = preferences()
|
||||
if (!prefs[payload.category]) return
|
||||
if (AppState.currentState === "active") return
|
||||
if (!(await granted())) return
|
||||
|
||||
if (payload.dedupeKey) {
|
||||
const now = Date.now()
|
||||
const lastSent = dedupeSentAt.get(payload.dedupeKey) || 0
|
||||
const cooldown = payload.dedupeCooldownMs ?? 30_000
|
||||
if (now - lastSent < cooldown) return
|
||||
dedupeSentAt.set(payload.dedupeKey, now)
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
data: {
|
||||
category: payload.category,
|
||||
sessionId: payload.sessionId,
|
||||
} satisfies NotificationData as Record<string, unknown>,
|
||||
sound: "default",
|
||||
...(Platform.OS === "android" ? { channelId: "prompts" } : {}),
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tap handler — returns cleanup function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function onTap(handler: (data: NotificationData) => void) {
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
const raw = response.notification.request.content.data
|
||||
const data = raw as unknown as NotificationData | undefined
|
||||
if (!data) return
|
||||
if (data.sessionId || data.category === "connection") {
|
||||
handler(data)
|
||||
}
|
||||
})
|
||||
return () => subscription.remove()
|
||||
}
|
||||
434
src/lib/sdk.ts
Normal file
434
src/lib/sdk.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
// SDK client wrapper for React Native
|
||||
// We create our own lightweight client that mirrors the opencode SDK patterns
|
||||
// but works in React Native environment
|
||||
// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
|
||||
import { fetch as expoFetch } from "expo/fetch"
|
||||
|
||||
export interface ClientConfig {
|
||||
baseUrl: string
|
||||
directory?: string
|
||||
auth?: {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
slug: string
|
||||
projectID: string
|
||||
directory: string
|
||||
parentID?: string
|
||||
title: string
|
||||
version: string
|
||||
share?: { url: string }
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
compacting?: number
|
||||
archived?: number
|
||||
}
|
||||
summary?: {
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user" | "assistant"
|
||||
parentID?: string
|
||||
time: {
|
||||
created: number
|
||||
completed?: number
|
||||
}
|
||||
// User message fields
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
// Assistant message fields
|
||||
modelID?: string
|
||||
providerID?: string
|
||||
cost?: number
|
||||
tokens?: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
cache?: { read: number; write: number }
|
||||
}
|
||||
error?: { message: string }
|
||||
finish?: string
|
||||
}
|
||||
|
||||
// API returns messages with parts embedded
|
||||
export interface MessageWithParts {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
id: string
|
||||
sessionID?: string
|
||||
messageID: string
|
||||
type:
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "tool"
|
||||
| "file"
|
||||
| "snapshot"
|
||||
| "patch"
|
||||
| "step-start"
|
||||
| "step-finish"
|
||||
| "subtask"
|
||||
| "retry"
|
||||
| "compaction"
|
||||
| "agent"
|
||||
// Text / reasoning part
|
||||
text?: string
|
||||
// Tool part
|
||||
tool?: string
|
||||
callID?: string
|
||||
state?: {
|
||||
status: "pending" | "running" | "completed" | "error"
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
title?: string
|
||||
error?: { message: string }
|
||||
time?: { start?: number; end?: number }
|
||||
}
|
||||
// Timing
|
||||
time?: { start?: number; end?: number }
|
||||
// File part
|
||||
mime?: string
|
||||
url?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
name: string
|
||||
description?: string
|
||||
mode: "subagent" | "primary" | "all"
|
||||
native?: boolean
|
||||
hidden?: boolean
|
||||
topP?: number
|
||||
temperature?: number
|
||||
color?: string
|
||||
model?: { modelID: string; providerID: string }
|
||||
prompt?: string
|
||||
options: Record<string, unknown>
|
||||
steps?: number
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
mcp?: boolean
|
||||
template: string
|
||||
subtask?: boolean
|
||||
hints: string[]
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name?: string
|
||||
path: {
|
||||
cwd: string
|
||||
root: string
|
||||
absolute: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
healthy: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
function createHeaders(config: ClientConfig): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if (config.directory) {
|
||||
const encoded = /[^\x00-\x7F]/.test(config.directory) ? encodeURIComponent(config.directory) : config.directory
|
||||
headers["x-opencode-directory"] = encoded
|
||||
}
|
||||
|
||||
if (config.auth) {
|
||||
const credentials = btoa(`${config.auth.username}:${config.auth.password}`)
|
||||
headers["Authorization"] = `Basic ${credentials}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${config.baseUrl}${path}`
|
||||
const headers = { ...createHeaders(config), ...options.headers }
|
||||
const response = await fetchWithTimeout(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`API Error: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const parentSignal = options.signal
|
||||
if (parentSignal?.aborted) throw new Error("Request aborted")
|
||||
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
controller.abort()
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
const onParentAbort = () => controller.abort()
|
||||
parentSignal?.addEventListener("abort", onParentAbort)
|
||||
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal })
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
parentSignal?.removeEventListener("abort", onParentAbort)
|
||||
}
|
||||
}
|
||||
|
||||
export function createClient(config: ClientConfig) {
|
||||
return {
|
||||
global: {
|
||||
health: () => request<HealthResponse>(config, "/global/health"),
|
||||
// SSE event stream - returns async iterator
|
||||
// Pass an AbortSignal to cancel the connection
|
||||
async *events(signal?: AbortSignal): AsyncGenerator<Event> {
|
||||
const url = `${config.baseUrl}/global/event`
|
||||
const headers = createHeaders(config)
|
||||
// Remove Content-Type for SSE (it's text/event-stream)
|
||||
delete (headers as Record<string, string>)["Content-Type"]
|
||||
|
||||
// Must use expo/fetch for ReadableStream support on native
|
||||
const response = await expoFetch(url, { headers, signal })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to connect to event stream: ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6)
|
||||
if (data && data !== "[DONE]") {
|
||||
try {
|
||||
yield JSON.parse(data)
|
||||
} catch (err) {
|
||||
console.warn("[SSE] Failed to parse event:", data.slice(0, 200), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
project: {
|
||||
list: () => request<Project[]>(config, "/project"),
|
||||
current: () => request<Project>(config, "/project/current"),
|
||||
},
|
||||
|
||||
path: {
|
||||
get: () =>
|
||||
request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
|
||||
},
|
||||
|
||||
session: {
|
||||
list: (params?: { roots?: boolean; limit?: number; search?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.roots) query.set("roots", "true")
|
||||
if (params?.limit) query.set("limit", String(params.limit))
|
||||
if (params?.search) query.set("search", params.search)
|
||||
const qs = query.toString()
|
||||
return request<Session[]>(config, `/session${qs ? `?${qs}` : ""}`)
|
||||
},
|
||||
|
||||
get: (sessionID: string) => request<Session>(config, `/session/${sessionID}`),
|
||||
|
||||
create: (params?: { title?: string }) =>
|
||||
request<Session>(config, "/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params || {}),
|
||||
}),
|
||||
|
||||
delete: (sessionID: string) => request<void>(config, `/session/${sessionID}`, { method: "DELETE" }),
|
||||
|
||||
update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) =>
|
||||
request<Session>(config, `/session/${sessionID}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
|
||||
messages: (sessionID: string, params?: { limit?: number }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.limit) query.set("limit", String(params.limit))
|
||||
const qs = query.toString()
|
||||
return request<MessageWithParts[]>(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`)
|
||||
},
|
||||
|
||||
// Sends a message and returns the response
|
||||
// Fire-and-forget async prompt - SSE events drive all real-time updates
|
||||
prompt: async (
|
||||
sessionID: string,
|
||||
params: {
|
||||
parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }>
|
||||
model?: { providerID: string; modelID: string }
|
||||
agent?: string
|
||||
variant?: string
|
||||
},
|
||||
): Promise<void> => {
|
||||
const url = `${config.baseUrl}/session/${sessionID}/prompt_async`
|
||||
const headers = createHeaders(config)
|
||||
const body = JSON.stringify(params)
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Failed to send message: ${response.status} - ${error}`)
|
||||
}
|
||||
},
|
||||
|
||||
command: async (
|
||||
sessionID: string,
|
||||
params: {
|
||||
command: string
|
||||
arguments: string
|
||||
agent?: string
|
||||
model?: string
|
||||
variant?: string
|
||||
parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }>
|
||||
},
|
||||
): Promise<void> => {
|
||||
const url = `${config.baseUrl}/session/${sessionID}/command`
|
||||
const headers = createHeaders(config)
|
||||
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ ...params, sessionID }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Failed to run command: ${response.status} - ${error}`)
|
||||
}
|
||||
},
|
||||
|
||||
abort: (sessionID: string) => request<boolean>(config, `/session/${sessionID}/abort`, { method: "POST" }),
|
||||
|
||||
diff: (sessionID: string, messageID?: string) => {
|
||||
const qs = messageID ? `?messageID=${messageID}` : ""
|
||||
return request<unknown[]>(config, `/session/${sessionID}/diff${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
permission: {
|
||||
list: () =>
|
||||
request<Array<{ id: string; sessionID: string; tool: string; input: unknown }>>(config, "/permission"),
|
||||
|
||||
reply: (requestID: string, reply: "once" | "always" | "reject") =>
|
||||
request<boolean>(config, `/permission/${requestID}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reply }),
|
||||
}),
|
||||
},
|
||||
|
||||
question: {
|
||||
list: () => request<Array<{ id: string; sessionID: string; questions: unknown[] }>>(config, "/question"),
|
||||
|
||||
reply: (requestID: string, answers: string[][]) =>
|
||||
request<boolean>(config, `/question/${requestID}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ answers }),
|
||||
}),
|
||||
|
||||
reject: (requestID: string) =>
|
||||
request<boolean>(config, `/question/${requestID}/reject`, {
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
|
||||
agent: {
|
||||
list: () => request<Agent[]>(config, "/agent"),
|
||||
},
|
||||
|
||||
command: {
|
||||
list: () => request<Command[]>(config, "/command"),
|
||||
},
|
||||
|
||||
provider: {
|
||||
list: () =>
|
||||
request<{
|
||||
all: Array<{
|
||||
id: string
|
||||
name: string
|
||||
models: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
attachment: boolean
|
||||
reasoning: boolean
|
||||
tool_call: boolean
|
||||
cost?: { input: number; output: number }
|
||||
limit: { context: number; output: number }
|
||||
status?: "alpha" | "beta" | "deprecated" | "active"
|
||||
}
|
||||
>
|
||||
}>
|
||||
default: Record<string, string>
|
||||
connected: string[]
|
||||
}>(config, "/provider"),
|
||||
},
|
||||
|
||||
config: {
|
||||
get: () => request<unknown>(config, "/config"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Client = ReturnType<typeof createClient>
|
||||
80
src/lib/speech.ts
Normal file
80
src/lib/speech.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition"
|
||||
|
||||
interface SpeechState {
|
||||
listening: boolean
|
||||
transcript: string
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface SpeechActions {
|
||||
start: () => Promise<void>
|
||||
stop: () => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function useSpeech(onResult: (text: string) => void): SpeechState & SpeechActions {
|
||||
const [listening, setListening] = useState(false)
|
||||
const [transcript, setTranscript] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pending = useRef("")
|
||||
|
||||
useSpeechRecognitionEvent("start", () => {
|
||||
setListening(true)
|
||||
setError(null)
|
||||
setTranscript("")
|
||||
pending.current = ""
|
||||
})
|
||||
|
||||
useSpeechRecognitionEvent("end", () => {
|
||||
setListening(false)
|
||||
// Deliver final transcript
|
||||
if (pending.current.trim()) {
|
||||
onResult(pending.current.trim())
|
||||
}
|
||||
setTranscript("")
|
||||
pending.current = ""
|
||||
})
|
||||
|
||||
useSpeechRecognitionEvent("result", (event) => {
|
||||
const text = event.results[0]?.transcript || ""
|
||||
pending.current = text
|
||||
setTranscript(text)
|
||||
})
|
||||
|
||||
useSpeechRecognitionEvent("error", (event) => {
|
||||
// "no-speech" is not really an error — user just didn't say anything
|
||||
if (event.error === "no-speech") {
|
||||
setListening(false)
|
||||
return
|
||||
}
|
||||
setError(event.message || event.error)
|
||||
setListening(false)
|
||||
})
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync()
|
||||
if (!result.granted) {
|
||||
setError("Microphone permission denied")
|
||||
return
|
||||
}
|
||||
ExpoSpeechRecognitionModule.start({
|
||||
lang: "en-US",
|
||||
interimResults: true,
|
||||
continuous: true,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
ExpoSpeechRecognitionModule.stop()
|
||||
}, [])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
pending.current = ""
|
||||
ExpoSpeechRecognitionModule.abort()
|
||||
setListening(false)
|
||||
setTranscript("")
|
||||
}, [])
|
||||
|
||||
return { listening, transcript, error, start, stop, cancel }
|
||||
}
|
||||
32
src/lib/types.ts
Normal file
32
src/lib/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Connection types for multiple server support
|
||||
export type ConnectionType = "local" | "tunnel" | "cloud"
|
||||
|
||||
export interface ServerConnection {
|
||||
id: string
|
||||
name: string
|
||||
type: ConnectionType
|
||||
url: string
|
||||
// For auth
|
||||
username?: string
|
||||
// Password stored separately in SecureStore
|
||||
// Directory to use for this connection
|
||||
directory?: string
|
||||
// When last successfully connected
|
||||
lastConnected?: number
|
||||
// Is this the active connection?
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
// Require biometric auth to open app
|
||||
requireBiometric: boolean
|
||||
// Require biometric to send messages
|
||||
requireBiometricForMessages: boolean
|
||||
// Theme preference
|
||||
theme: "light" | "dark" | "system"
|
||||
// Show notifications for task completion
|
||||
notifications: boolean
|
||||
}
|
||||
|
||||
// Re-export SDK types we'll use frequently
|
||||
export type { Session, Message, Part, Project, Event, HealthResponse } from "./sdk"
|
||||
Reference in New Issue
Block a user