Initial commit: OpenCode mobile app (extracted from opencode monorepo)
This commit is contained in:
135
src/stores/auth.ts
Normal file
135
src/stores/auth.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { create } from "zustand"
|
||||
import * as LocalAuthentication from "expo-local-authentication"
|
||||
import * as SecureStore from "expo-secure-store"
|
||||
|
||||
const AUTH_SETTINGS_KEY = "opencode_auth_settings"
|
||||
|
||||
interface AuthSettings {
|
||||
requireBiometric: boolean
|
||||
requireBiometricForMessages: boolean
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
hasBiometrics: boolean
|
||||
biometricType: LocalAuthentication.AuthenticationType | null
|
||||
settings: AuthSettings
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>
|
||||
authenticate: () => Promise<boolean>
|
||||
authenticateForMessage: () => Promise<boolean>
|
||||
updateSettings: (settings: Partial<AuthSettings>) => Promise<void>
|
||||
lock: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AuthSettings = {
|
||||
requireBiometric: false,
|
||||
requireBiometricForMessages: false,
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set, get) => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
hasBiometrics: false,
|
||||
biometricType: null,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
error: null,
|
||||
|
||||
initialize: async () => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
// Check biometric availability
|
||||
const hasHardware = await LocalAuthentication.hasHardwareAsync()
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
|
||||
const hasBiometrics = hasHardware && isEnrolled
|
||||
|
||||
let biometricType: LocalAuthentication.AuthenticationType | null = null
|
||||
if (hasBiometrics) {
|
||||
const types = await LocalAuthentication.supportedAuthenticationTypesAsync()
|
||||
biometricType = types[0] || null
|
||||
}
|
||||
|
||||
// Load settings
|
||||
const stored = await SecureStore.getItemAsync(AUTH_SETTINGS_KEY)
|
||||
const settings: AuthSettings = stored ? JSON.parse(stored) : DEFAULT_SETTINGS
|
||||
|
||||
// If biometric is not required, auto-authenticate
|
||||
const isAuthenticated = !settings.requireBiometric
|
||||
|
||||
set({
|
||||
hasBiometrics,
|
||||
biometricType,
|
||||
settings,
|
||||
isAuthenticated,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: "Failed to initialize authentication",
|
||||
isLoading: false,
|
||||
isAuthenticated: true, // Fail open for usability
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
authenticate: async () => {
|
||||
const { settings, hasBiometrics } = get()
|
||||
|
||||
if (!settings.requireBiometric || !hasBiometrics) {
|
||||
set({ isAuthenticated: true })
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: "Authenticate to access OpenCode",
|
||||
fallbackLabel: "Use passcode",
|
||||
disableDeviceFallback: false,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
set({ isAuthenticated: true, error: null })
|
||||
return true
|
||||
}
|
||||
|
||||
set({ error: result.error || "Authentication failed" })
|
||||
return false
|
||||
} catch (error) {
|
||||
set({ error: "Authentication error" })
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
authenticateForMessage: async () => {
|
||||
const { settings, hasBiometrics, isAuthenticated } = get()
|
||||
|
||||
if (!isAuthenticated) return false
|
||||
if (!settings.requireBiometricForMessages || !hasBiometrics) return true
|
||||
|
||||
try {
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: "Authenticate to send message",
|
||||
fallbackLabel: "Use passcode",
|
||||
disableDeviceFallback: false,
|
||||
})
|
||||
|
||||
return result.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateSettings: async (updates) => {
|
||||
const settings = { ...get().settings, ...updates }
|
||||
await SecureStore.setItemAsync(AUTH_SETTINGS_KEY, JSON.stringify(settings))
|
||||
set({ settings })
|
||||
},
|
||||
|
||||
lock: () => {
|
||||
set({ isAuthenticated: false })
|
||||
},
|
||||
}))
|
||||
139
src/stores/catalog.ts
Normal file
139
src/stores/catalog.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { create } from "zustand"
|
||||
import { useConnections } from "./connections"
|
||||
import type { Agent, Command } from "../lib/sdk"
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string
|
||||
name: string
|
||||
reasoning: boolean
|
||||
attachment: boolean
|
||||
limit?: { context: number; output: number }
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
connected: boolean
|
||||
models: ProviderModel[]
|
||||
}
|
||||
|
||||
interface ModelSelection {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
interface CatalogState {
|
||||
agents: Agent[]
|
||||
commands: Command[]
|
||||
providers: Provider[]
|
||||
defaults: Record<string, string>
|
||||
// Current selections
|
||||
agent: string // agent name, e.g. "build"
|
||||
model: ModelSelection | null
|
||||
loaded: boolean
|
||||
|
||||
// Actions
|
||||
load: () => Promise<void>
|
||||
setAgent: (name: string) => void
|
||||
setModel: (selection: ModelSelection | null) => void
|
||||
cycleAgent: (direction?: 1 | -1) => void
|
||||
}
|
||||
|
||||
export const useCatalog = create<CatalogState>((set, get) => ({
|
||||
agents: [],
|
||||
commands: [],
|
||||
providers: [],
|
||||
defaults: {},
|
||||
agent: "",
|
||||
model: null,
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
const client = useConnections.getState().client
|
||||
if (!client) return
|
||||
|
||||
const [agentResult, commandResult, providerResult] = await Promise.all([
|
||||
client.agent.list().catch(() => [] as Agent[]),
|
||||
client.command.list().catch(() => [] as Command[]),
|
||||
client.provider.list().catch(() => null),
|
||||
])
|
||||
|
||||
const agents = Array.isArray(agentResult) ? agentResult : []
|
||||
const commands = Array.isArray(commandResult) ? commandResult : []
|
||||
|
||||
// Parse provider response: { all: [...], default: {...}, connected: [...] }
|
||||
const raw = providerResult
|
||||
const connected = new Set(Array.isArray(raw?.connected) ? raw.connected : [])
|
||||
const defaults = raw?.default || {}
|
||||
const providers: Provider[] = Array.isArray(raw?.all)
|
||||
? raw.all
|
||||
.filter((p) => connected.has(p.id))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name || p.id,
|
||||
connected: connected.has(p.id),
|
||||
models: Object.values(p.models || {})
|
||||
.filter((m) => m.status !== "deprecated")
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
reasoning: m.reasoning ?? false,
|
||||
attachment: m.attachment ?? false,
|
||||
limit: m.limit,
|
||||
})),
|
||||
}))
|
||||
.filter((p) => p.models.length > 0)
|
||||
: []
|
||||
|
||||
console.log(
|
||||
"[catalog] loaded:",
|
||||
agents.length,
|
||||
"agents,",
|
||||
commands.length,
|
||||
"commands,",
|
||||
providers.length,
|
||||
"providers (" + providers.reduce((n, p) => n + p.models.length, 0) + " models)",
|
||||
)
|
||||
|
||||
// Filter out hidden agents
|
||||
const visible = agents.filter((a) => !a.hidden)
|
||||
|
||||
// Default agent
|
||||
const current = get().agent
|
||||
const agent = current && visible.some((a) => a.name === current) ? current : visible[0]?.name || "build"
|
||||
|
||||
// Default model: use default agent's model, or first connected provider's default model
|
||||
const existing = get().model
|
||||
const fallback = (() => {
|
||||
const defaultAgent = visible[0]
|
||||
if (defaultAgent?.model) return defaultAgent.model
|
||||
for (const p of providers) {
|
||||
const defaultModelID = defaults[p.id]
|
||||
const match = defaultModelID ? p.models.find((m) => m.id === defaultModelID) : p.models[0]
|
||||
if (match) return { providerID: p.id, modelID: match.id }
|
||||
}
|
||||
return null
|
||||
})()
|
||||
const model = existing || fallback
|
||||
|
||||
set({ agents: visible, commands, providers, defaults, agent, model, loaded: true })
|
||||
},
|
||||
|
||||
setAgent: (name) => {
|
||||
const match = get().agents.find((a) => a.name === name)
|
||||
if (!match) return
|
||||
const model = match.model || get().model
|
||||
set({ agent: name, model })
|
||||
},
|
||||
|
||||
setModel: (selection) => set({ model: selection }),
|
||||
|
||||
cycleAgent: (direction = 1) => {
|
||||
const { agents, agent } = get()
|
||||
const primary = agents.filter((a) => a.mode === "primary" || a.mode === "all")
|
||||
if (primary.length < 2) return
|
||||
const idx = primary.findIndex((a) => a.name === agent)
|
||||
const next = (idx + direction + primary.length) % primary.length
|
||||
get().setAgent(primary[next].name)
|
||||
},
|
||||
}))
|
||||
312
src/stores/connections.ts
Normal file
312
src/stores/connections.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { create } from "zustand"
|
||||
import * as SecureStore from "expo-secure-store"
|
||||
import type { ServerConnection, ConnectionType } from "../lib/types"
|
||||
import { createClient, type Client, type Project } from "../lib/sdk"
|
||||
|
||||
const CONNECTIONS_KEY = "opencode_connections"
|
||||
const PASSWORDS_PREFIX = "opencode_password_"
|
||||
const RECENT_DIRS_KEY = "opencode_recent_dirs"
|
||||
const MAX_RECENT_DIRS = 10
|
||||
|
||||
// Cached auth so we can create directory-scoped clients without async SecureStore lookups
|
||||
interface ClientBase {
|
||||
baseUrl: string
|
||||
auth?: { username: string; password: string }
|
||||
}
|
||||
|
||||
interface ConnectionsState {
|
||||
connections: ServerConnection[]
|
||||
activeConnection: ServerConnection | null
|
||||
client: Client | null
|
||||
clientBase: ClientBase | null
|
||||
currentProject: Project | null
|
||||
serverHome: string | null // Home directory on the server machine (for ~ expansion)
|
||||
recentDirectories: string[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loadConnections: () => Promise<void>
|
||||
addConnection: (connection: Omit<ServerConnection, "id">, password?: string) => Promise<void>
|
||||
removeConnection: (id: string) => Promise<void>
|
||||
setActiveConnection: (id: string) => Promise<void>
|
||||
testConnection: (connection: ServerConnection, password?: string) => Promise<boolean>
|
||||
updateConnection: (id: string, updates: Partial<ServerConnection>) => Promise<void>
|
||||
refreshProject: () => Promise<void>
|
||||
// Create a one-off client pointing at a specific directory (for cross-project operations)
|
||||
clientForDirectory: (directory: string) => Client | null
|
||||
// Switch the active connection's directory and reload
|
||||
switchDirectory: (directory?: string) => Promise<void>
|
||||
// Record a directory as recently used
|
||||
addRecentDirectory: (directory: string) => Promise<void>
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).slice(2, 11)
|
||||
}
|
||||
|
||||
function buildClient(
|
||||
url: string,
|
||||
directory?: string,
|
||||
auth?: { username: string; password: string },
|
||||
): { client: Client; base: ClientBase } {
|
||||
const base: ClientBase = { baseUrl: url, auth }
|
||||
const client = createClient({ baseUrl: url, directory, auth })
|
||||
return { client, base }
|
||||
}
|
||||
|
||||
export const useConnections = create<ConnectionsState>((set, get) => ({
|
||||
connections: [],
|
||||
activeConnection: null,
|
||||
client: null,
|
||||
clientBase: null,
|
||||
serverHome: null,
|
||||
currentProject: null,
|
||||
recentDirectories: [],
|
||||
isLoading: true,
|
||||
error: null,
|
||||
|
||||
loadConnections: async () => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const [stored, recentRaw] = await Promise.all([
|
||||
SecureStore.getItemAsync(CONNECTIONS_KEY),
|
||||
SecureStore.getItemAsync(RECENT_DIRS_KEY),
|
||||
])
|
||||
const connections: ServerConnection[] = stored ? JSON.parse(stored) : []
|
||||
const recentDirectories: string[] = recentRaw ? JSON.parse(recentRaw) : []
|
||||
|
||||
// Find active connection
|
||||
const active = connections.find((c) => c.active) || null
|
||||
|
||||
// Create client for active connection
|
||||
let client: Client | null = null
|
||||
let base: ClientBase | null = null
|
||||
let project: Project | null = null
|
||||
let home: string | null = null
|
||||
if (active) {
|
||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
|
||||
const auth = active.username && password ? { username: active.username, password } : undefined
|
||||
const built = buildClient(active.url, active.directory, auth)
|
||||
client = built.client
|
||||
base = built.base
|
||||
// Fetch current project info and server paths
|
||||
try {
|
||||
const [proj, paths] = await Promise.all([
|
||||
client.project.current().catch(() => null),
|
||||
client.path.get().catch(() => null),
|
||||
])
|
||||
project = proj
|
||||
home = paths?.home || null
|
||||
} catch {
|
||||
// Server might be offline
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
connections,
|
||||
activeConnection: active,
|
||||
client,
|
||||
clientBase: base,
|
||||
currentProject: project,
|
||||
serverHome: home,
|
||||
recentDirectories,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (error) {
|
||||
set({ error: "Failed to load connections", isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
addConnection: async (connection, password) => {
|
||||
const id = generateId()
|
||||
const newConnection: ServerConnection = {
|
||||
...connection,
|
||||
id,
|
||||
active: get().connections.length === 0, // First connection is active
|
||||
}
|
||||
|
||||
const connections = [...get().connections, newConnection]
|
||||
|
||||
// Store password separately if provided
|
||||
if (password) {
|
||||
await SecureStore.setItemAsync(`${PASSWORDS_PREFIX}${id}`, password)
|
||||
}
|
||||
|
||||
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
||||
|
||||
// If this is the first/active connection, create client
|
||||
let client = get().client
|
||||
let base = get().clientBase
|
||||
let activeConnection = get().activeConnection
|
||||
|
||||
if (newConnection.active) {
|
||||
activeConnection = newConnection
|
||||
const auth = newConnection.username && password ? { username: newConnection.username, password } : undefined
|
||||
const built = buildClient(newConnection.url, newConnection.directory, auth)
|
||||
client = built.client
|
||||
base = built.base
|
||||
}
|
||||
|
||||
set({ connections, activeConnection, client, clientBase: base })
|
||||
},
|
||||
|
||||
removeConnection: async (id) => {
|
||||
const connections = get().connections.filter((c) => c.id !== id)
|
||||
|
||||
// Remove stored password
|
||||
await SecureStore.deleteItemAsync(`${PASSWORDS_PREFIX}${id}`)
|
||||
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
||||
|
||||
// If removing active connection, clear client
|
||||
const wasActive = get().activeConnection?.id === id
|
||||
if (wasActive) {
|
||||
const newActive = connections[0] || null
|
||||
if (newActive) {
|
||||
// Mark new connection as active
|
||||
newActive.active = true
|
||||
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`)
|
||||
const auth = newActive.username && password ? { username: newActive.username, password } : undefined
|
||||
const built = buildClient(newActive.url, newActive.directory, auth)
|
||||
set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base })
|
||||
} else {
|
||||
set({ connections, activeConnection: null, client: null, clientBase: null })
|
||||
}
|
||||
} else {
|
||||
set({ connections })
|
||||
}
|
||||
},
|
||||
|
||||
setActiveConnection: async (id) => {
|
||||
const connections = get().connections.map((c) => ({
|
||||
...c,
|
||||
active: c.id === id,
|
||||
}))
|
||||
|
||||
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
||||
|
||||
const active = connections.find((c) => c.id === id) || null
|
||||
let client: Client | null = null
|
||||
let base: ClientBase | null = null
|
||||
let project: Project | null = null
|
||||
let home: string | null = null
|
||||
|
||||
if (active) {
|
||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
|
||||
const auth = active.username && password ? { username: active.username, password } : undefined
|
||||
const built = buildClient(active.url, active.directory, auth)
|
||||
client = built.client
|
||||
base = built.base
|
||||
|
||||
try {
|
||||
const [proj, paths] = await Promise.all([
|
||||
client.project.current().catch(() => null),
|
||||
client.path.get().catch(() => null),
|
||||
])
|
||||
project = proj
|
||||
home = paths?.home || null
|
||||
} catch {
|
||||
// Server might be offline
|
||||
}
|
||||
|
||||
// Update last connected time
|
||||
active.lastConnected = Date.now()
|
||||
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
||||
}
|
||||
|
||||
set({ connections, activeConnection: active, client, clientBase: base, currentProject: project, serverHome: home })
|
||||
},
|
||||
|
||||
testConnection: async (connection, password) => {
|
||||
try {
|
||||
const client = createClient({
|
||||
baseUrl: connection.url,
|
||||
directory: connection.directory,
|
||||
auth: connection.username && password ? { username: connection.username, password } : undefined,
|
||||
})
|
||||
|
||||
await client.global.health()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
updateConnection: async (id, updates) => {
|
||||
const connections = get().connections.map((c) => (c.id === id ? { ...c, ...updates } : c))
|
||||
|
||||
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
||||
|
||||
// If updating active connection, recreate client
|
||||
if (get().activeConnection?.id === id) {
|
||||
const active = connections.find((c) => c.id === id)!
|
||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`)
|
||||
const auth = active.username && password ? { username: active.username, password } : undefined
|
||||
const built = buildClient(active.url, active.directory, auth)
|
||||
try {
|
||||
const [project, paths] = await Promise.all([
|
||||
built.client.project.current().catch(() => null),
|
||||
built.client.path.get().catch(() => null),
|
||||
])
|
||||
set({
|
||||
connections,
|
||||
activeConnection: active,
|
||||
client: built.client,
|
||||
clientBase: built.base,
|
||||
currentProject: project,
|
||||
serverHome: paths?.home || null,
|
||||
})
|
||||
} catch {
|
||||
set({
|
||||
connections,
|
||||
activeConnection: active,
|
||||
client: built.client,
|
||||
clientBase: built.base,
|
||||
currentProject: null,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
set({ connections })
|
||||
}
|
||||
},
|
||||
|
||||
refreshProject: async () => {
|
||||
const client = get().client
|
||||
if (!client) return
|
||||
|
||||
try {
|
||||
const project = await client.project.current()
|
||||
set({ currentProject: project })
|
||||
} catch {
|
||||
set({ currentProject: null })
|
||||
}
|
||||
},
|
||||
|
||||
clientForDirectory: (directory) => {
|
||||
const base = get().clientBase
|
||||
if (!base) return null
|
||||
// Reuse current client if directory matches
|
||||
const active = get().activeConnection
|
||||
if (active?.directory === directory) return get().client
|
||||
return createClient({ baseUrl: base.baseUrl, directory, auth: base.auth })
|
||||
},
|
||||
|
||||
switchDirectory: async (directory) => {
|
||||
const active = get().activeConnection
|
||||
if (!active) return
|
||||
// Update connection directory and recreate client
|
||||
const dir = directory?.trim() || undefined
|
||||
await get().updateConnection(active.id, { directory: dir })
|
||||
// Record in recents if it's a real directory
|
||||
if (dir) await get().addRecentDirectory(dir)
|
||||
},
|
||||
|
||||
addRecentDirectory: async (directory) => {
|
||||
const current = get().recentDirectories
|
||||
// Move to front, dedup, cap at MAX
|
||||
const updated = [directory, ...current.filter((d) => d !== directory)].slice(0, MAX_RECENT_DIRS)
|
||||
set({ recentDirectories: updated })
|
||||
await SecureStore.setItemAsync(RECENT_DIRS_KEY, JSON.stringify(updated))
|
||||
},
|
||||
}))
|
||||
371
src/stores/events.ts
Normal file
371
src/stores/events.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { create } from "zustand"
|
||||
import { useConnections } from "./connections"
|
||||
import { useSessions } from "./sessions"
|
||||
import { send as notify } from "../lib/notifications"
|
||||
import type { Client, Part, Session, Message } from "../lib/sdk"
|
||||
|
||||
// Session status from the server
|
||||
type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string }
|
||||
|
||||
// Tool status labels derived from part type
|
||||
const TOOL_STATUS: Record<string, string> = {
|
||||
read: "Gathering context...",
|
||||
list: "Searching codebase...",
|
||||
grep: "Searching codebase...",
|
||||
glob: "Searching codebase...",
|
||||
webfetch: "Searching web...",
|
||||
edit: "Making edits...",
|
||||
write: "Making edits...",
|
||||
apply_patch: "Making edits...",
|
||||
bash: "Running command...",
|
||||
task: "Delegating...",
|
||||
todowrite: "Planning...",
|
||||
todoread: "Planning...",
|
||||
}
|
||||
|
||||
function statusFromPart(part: Part): string {
|
||||
if (part.type === "reasoning") return "Thinking..."
|
||||
if (part.type === "tool" && part.tool) return TOOL_STATUS[part.tool] || `Running ${part.tool}...`
|
||||
if (part.type === "text") return "Writing..."
|
||||
return "Working..."
|
||||
}
|
||||
|
||||
interface EventsState {
|
||||
connected: boolean
|
||||
reconnectAttempts: number
|
||||
lastDisconnectAt: number | null
|
||||
sessionStatus: Record<string, SessionStatus>
|
||||
statusText: Record<string, string>
|
||||
// Permissions & questions (pending per session)
|
||||
permissions: Record<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata: Record<string, unknown>
|
||||
tool?: { messageID: string; callID: string }
|
||||
}>
|
||||
>
|
||||
questions: Record<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
sessionID: string
|
||||
questions: Array<{
|
||||
question: string
|
||||
header: string
|
||||
options: Array<{ label: string; description: string }>
|
||||
multiple?: boolean
|
||||
custom?: boolean
|
||||
}>
|
||||
tool?: { messageID: string; callID: string }
|
||||
}>
|
||||
>
|
||||
|
||||
connect: () => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
let controller: AbortController | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const
|
||||
const STABLE_CONNECTION_MS = 10_000
|
||||
const PROLONGED_DISCONNECT_MS = 30_000
|
||||
|
||||
// Re-fetch pending permissions and questions from the server for a session.
|
||||
// Called when entering a session to recover from missed SSE events or failed
|
||||
// optimistic removals.
|
||||
export async function refreshPending(client: Client, sessionID: string) {
|
||||
try {
|
||||
const [perms, questions] = await Promise.all([client.permission.list(), client.question.list()])
|
||||
const sessionPerms = (perms || []).filter((p: Record<string, unknown>) => p.sessionID === sessionID)
|
||||
const sessionQuestions = (questions || []).filter((q: Record<string, unknown>) => q.sessionID === sessionID)
|
||||
useEvents.setState((state) => ({
|
||||
permissions: { ...state.permissions, [sessionID]: sessionPerms as any },
|
||||
questions: { ...state.questions, [sessionID]: sessionQuestions as any },
|
||||
}))
|
||||
} catch (err) {
|
||||
console.warn("[Events] Failed to refresh pending:", err)
|
||||
}
|
||||
}
|
||||
|
||||
export const useEvents = create<EventsState>((set, get) => ({
|
||||
connected: false,
|
||||
reconnectAttempts: 0,
|
||||
lastDisconnectAt: null,
|
||||
sessionStatus: {},
|
||||
statusText: {},
|
||||
permissions: {},
|
||||
questions: {},
|
||||
|
||||
connect: () => {
|
||||
controller?.abort()
|
||||
controller = null
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
const client = useConnections.getState().client
|
||||
if (!client) return
|
||||
|
||||
controller = new AbortController()
|
||||
const currentController = controller
|
||||
set({ connected: true })
|
||||
console.log("[SSE] Connecting to event stream...")
|
||||
|
||||
// Run in background
|
||||
;(async () => {
|
||||
let reconnectScheduled = false
|
||||
const stableTimer = setTimeout(() => {
|
||||
if (!currentController.signal.aborted) {
|
||||
set({ reconnectAttempts: 0, lastDisconnectAt: null })
|
||||
}
|
||||
}, STABLE_CONNECTION_MS)
|
||||
|
||||
const scheduleReconnect = (reason: unknown) => {
|
||||
if (reconnectScheduled || currentController.signal.aborted) return
|
||||
reconnectScheduled = true
|
||||
const state = get()
|
||||
const reconnectAttempts = state.reconnectAttempts + 1
|
||||
const lastDisconnectAt = state.lastDisconnectAt ?? Date.now()
|
||||
const disconnectedFor = Date.now() - lastDisconnectAt
|
||||
set({ connected: false, reconnectAttempts, lastDisconnectAt })
|
||||
|
||||
if (disconnectedFor >= PROLONGED_DISCONNECT_MS) {
|
||||
notify({
|
||||
category: "connection",
|
||||
title: "Connection interrupted",
|
||||
body: "Trying to reconnect to your server",
|
||||
sessionId: "",
|
||||
dedupeKey: "sse-prolonged-disconnect",
|
||||
dedupeCooldownMs: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempts - 1, RECONNECT_DELAYS_MS.length - 1)]
|
||||
const jitteredDelay = Math.min(15_000, Math.round(baseDelay * (0.75 + Math.random() * 0.5)))
|
||||
console.warn(`[SSE] Connection lost, reconnecting in ${jitteredDelay}ms:`, reason)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
get().connect()
|
||||
}, jitteredDelay)
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const event of client.global.events(currentController.signal)) {
|
||||
if (currentController.signal.aborted) break
|
||||
|
||||
const payload = (event as any).payload || event
|
||||
const type = payload.type as string
|
||||
const props = payload.properties || {}
|
||||
|
||||
switch (type) {
|
||||
case "session.status": {
|
||||
const sessionID = props.sessionID as string
|
||||
const status = props.status as SessionStatus
|
||||
if (!sessionID) break
|
||||
|
||||
// Detect busy → idle transition for completion notification
|
||||
const previous = get().sessionStatus[sessionID]
|
||||
const completed = previous?.type === "busy" && status.type === "idle"
|
||||
|
||||
set((state) => ({
|
||||
sessionStatus: { ...state.sessionStatus, [sessionID]: status },
|
||||
// Clear status text when idle
|
||||
statusText: status.type === "idle" ? { ...state.statusText, [sessionID]: "" } : state.statusText,
|
||||
}))
|
||||
|
||||
// SSE is the source of truth — update sending state unconditionally
|
||||
if (status.type === "idle") {
|
||||
useSessions.setState((state) => ({
|
||||
sending: { ...state.sending, [sessionID]: false },
|
||||
}))
|
||||
// Refresh messages if this is the session the user is viewing
|
||||
const sessions = useSessions.getState()
|
||||
if (sessions.currentSession?.id === sessionID) {
|
||||
sessions.refreshMessages()
|
||||
}
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
const match = useSessions.getState().sessions.find((s) => s.id === sessionID)
|
||||
notify({
|
||||
category: "completed",
|
||||
title: "Task completed",
|
||||
body: match?.title || "Session finished processing",
|
||||
sessionId: sessionID,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "message.updated": {
|
||||
const info = props.info as Message | undefined
|
||||
if (!info) break
|
||||
useSessions.getState().handleEvent({ type, properties: { info } } as any)
|
||||
break
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
const part = props.part as Part | undefined
|
||||
if (!part) break
|
||||
|
||||
// Update status text from the latest part
|
||||
const sessionID = (part as any).sessionID as string
|
||||
if (sessionID) {
|
||||
set((state) => ({
|
||||
statusText: { ...state.statusText, [sessionID]: statusFromPart(part) },
|
||||
}))
|
||||
}
|
||||
|
||||
useSessions.getState().handleEvent({ type, properties: { part } } as any)
|
||||
break
|
||||
}
|
||||
|
||||
case "session.updated": {
|
||||
const info = props.info as Session | undefined
|
||||
if (!info) break
|
||||
useSessions.getState().handleEvent({ type, properties: { info } } as any)
|
||||
break
|
||||
}
|
||||
|
||||
case "session.created": {
|
||||
const info = props.info as Session | undefined
|
||||
if (!info) break
|
||||
// Add to sessions list
|
||||
useSessions.setState((state) => {
|
||||
const exists = state.sessions.some((s) => s.id === info.id)
|
||||
if (exists) return {}
|
||||
return { sessions: [info, ...state.sessions] }
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "session.error": {
|
||||
const error = props.error as { message?: string } | undefined
|
||||
const sessionID = props.sessionID as string
|
||||
if (!sessionID) break
|
||||
// Clear sending state unconditionally — SSE is truth
|
||||
useSessions.setState((state) => ({
|
||||
sending: { ...state.sending, [sessionID]: false },
|
||||
// Surface error only if user is viewing this session
|
||||
...(state.currentSession?.id === sessionID
|
||||
? { error: error?.message || "Session error occurred" }
|
||||
: {}),
|
||||
}))
|
||||
if (useSessions.getState().currentSession?.id === sessionID) {
|
||||
useSessions.getState().refreshMessages()
|
||||
}
|
||||
notify({
|
||||
category: "errors",
|
||||
title: "Session error",
|
||||
body: error?.message || "Something went wrong",
|
||||
sessionId: sessionID,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "permission.asked": {
|
||||
const req = props as any
|
||||
if (!req.id || !req.sessionID) break
|
||||
const existing = get().permissions[req.sessionID] || []
|
||||
if (existing.some((item) => item.id === req.id)) break
|
||||
set((state) => ({
|
||||
permissions: {
|
||||
...state.permissions,
|
||||
[req.sessionID]: [...(state.permissions[req.sessionID] || []), req],
|
||||
},
|
||||
}))
|
||||
notify({
|
||||
category: "permissions",
|
||||
title: req.permission || "Permission requested",
|
||||
body: req.patterns?.join(", ") || "A tool needs your approval",
|
||||
sessionId: req.sessionID,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "permission.replied": {
|
||||
const sessionID = props.sessionID as string
|
||||
const requestID = props.requestID as string
|
||||
if (!sessionID || !requestID) break
|
||||
set((state) => ({
|
||||
permissions: {
|
||||
...state.permissions,
|
||||
[sessionID]: (state.permissions[sessionID] || []).filter((p) => p.id !== requestID),
|
||||
},
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case "question.asked": {
|
||||
const req = props as any
|
||||
if (!req.id || !req.sessionID) break
|
||||
const existing = get().questions[req.sessionID] || []
|
||||
if (existing.some((item) => item.id === req.id)) break
|
||||
set((state) => ({
|
||||
questions: {
|
||||
...state.questions,
|
||||
[req.sessionID]: [...(state.questions[req.sessionID] || []), req],
|
||||
},
|
||||
}))
|
||||
notify({
|
||||
category: "questions",
|
||||
title: req.questions?.[0]?.header || "Input needed",
|
||||
body: req.questions?.[0]?.question || "The assistant has a question",
|
||||
sessionId: req.sessionID,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const sessionID = props.sessionID as string
|
||||
const requestID = props.requestID as string
|
||||
if (!sessionID || !requestID) break
|
||||
set((state) => ({
|
||||
questions: {
|
||||
...state.questions,
|
||||
[sessionID]: (state.questions[sessionID] || []).filter((q) => q.id !== requestID),
|
||||
},
|
||||
}))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect(new Error("Event stream closed"))
|
||||
} catch (err) {
|
||||
scheduleReconnect(err)
|
||||
} finally {
|
||||
clearTimeout(stableTimer)
|
||||
if (currentController.signal.aborted) {
|
||||
console.log("[SSE] Disconnected (aborted)")
|
||||
}
|
||||
}
|
||||
})()
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
console.log("[SSE] Disconnecting")
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
controller?.abort()
|
||||
controller = null
|
||||
set({
|
||||
connected: false,
|
||||
reconnectAttempts: 0,
|
||||
lastDisconnectAt: null,
|
||||
sessionStatus: {},
|
||||
statusText: {},
|
||||
permissions: {},
|
||||
questions: {},
|
||||
})
|
||||
},
|
||||
}))
|
||||
374
src/stores/sessions.ts
Normal file
374
src/stores/sessions.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { create } from "zustand"
|
||||
import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk"
|
||||
import { useConnections } from "./connections"
|
||||
import { useSettings } from "./settings"
|
||||
|
||||
// Helper to convert API response to our internal format
|
||||
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
|
||||
const messages: Message[] = []
|
||||
const parts: Record<string, Part[]> = {}
|
||||
|
||||
for (const item of response || []) {
|
||||
messages.push(item.info)
|
||||
parts[item.info.id] = item.parts || []
|
||||
}
|
||||
|
||||
return { messages, parts }
|
||||
}
|
||||
|
||||
function pageSize(): number {
|
||||
return useSettings.getState().pageSize
|
||||
}
|
||||
|
||||
interface SessionsState {
|
||||
sessions: Session[]
|
||||
currentSession: Session | null
|
||||
messages: Message[]
|
||||
parts: Record<string, Part[]>
|
||||
isLoading: boolean
|
||||
// Per-session optimistic sending flag — bridging gap between user tap and SSE busy
|
||||
sending: Record<string, boolean>
|
||||
loadingMore: boolean
|
||||
hasMore: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loadSessions: () => Promise<void>
|
||||
selectSession: (sessionID: string, directory?: string) => Promise<void>
|
||||
loadOlderMessages: () => Promise<void>
|
||||
createSession: (title?: string) => Promise<Session | null>
|
||||
deleteSession: (sessionID: string) => Promise<void>
|
||||
sendMessage: (
|
||||
text: string,
|
||||
model?: { providerID: string; modelID: string },
|
||||
agent?: string,
|
||||
files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>,
|
||||
) => Promise<void>
|
||||
abortSession: () => Promise<void>
|
||||
refreshMessages: () => Promise<void>
|
||||
|
||||
// Event handling
|
||||
handleEvent: (event: Event) => void
|
||||
}
|
||||
|
||||
// Get the right client for a session's directory
|
||||
function clientFor(directory?: string): Client | null {
|
||||
const connState = useConnections.getState()
|
||||
if (!directory) return connState.client
|
||||
const connDir = connState.activeConnection?.directory
|
||||
if (directory !== connDir) return connState.clientForDirectory(directory)
|
||||
return connState.client
|
||||
}
|
||||
|
||||
export const useSessions = create<SessionsState>((set, get) => ({
|
||||
sessions: [],
|
||||
currentSession: null,
|
||||
messages: [],
|
||||
parts: {},
|
||||
isLoading: false,
|
||||
sending: {},
|
||||
loadingMore: false,
|
||||
hasMore: false,
|
||||
error: null,
|
||||
|
||||
loadSessions: async () => {
|
||||
const client = useConnections.getState().client
|
||||
if (!client) {
|
||||
set({ error: "No active connection" })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const sessions = await client.session.list({ roots: true, limit: 50 })
|
||||
set({ sessions, isLoading: false })
|
||||
} catch (error) {
|
||||
set({ error: "Failed to load sessions", isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectSession: async (sessionID, directory) => {
|
||||
// Use directory-specific client if the session belongs to a different project
|
||||
const connState = useConnections.getState()
|
||||
const client = directory ? connState.clientForDirectory(directory) : connState.client
|
||||
if (!client) {
|
||||
set({ error: "No active connection" })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Reset optimistic sending — SSE sessionStatus is the source of truth
|
||||
set((state) => ({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
hasMore: false,
|
||||
loadingMore: false,
|
||||
sending: { ...state.sending, [sessionID]: false },
|
||||
}))
|
||||
|
||||
const [session, messagesResponse] = await Promise.all([
|
||||
client.session.get(sessionID),
|
||||
client.session.messages(sessionID, { limit: pageSize() }),
|
||||
])
|
||||
|
||||
// Parse the API response format: array of { info, parts }
|
||||
const { messages, parts } = parseMessages(messagesResponse)
|
||||
|
||||
set({
|
||||
currentSession: session,
|
||||
messages,
|
||||
parts,
|
||||
isLoading: false,
|
||||
// If we got exactly PAGE_SIZE messages, there are probably more
|
||||
hasMore: messagesResponse.length >= pageSize(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to load session:", err)
|
||||
set({ error: "Failed to load session", isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
loadOlderMessages: async () => {
|
||||
const client = clientFor(get().currentSession?.directory)
|
||||
const session = get().currentSession
|
||||
if (!client || !session) return
|
||||
if (get().loadingMore || !get().hasMore) return
|
||||
|
||||
try {
|
||||
set({ loadingMore: true })
|
||||
|
||||
// Fetch ALL messages for this session
|
||||
const response = await client.session.messages(session.id)
|
||||
const { messages: all, parts: allParts } = parseMessages(response)
|
||||
|
||||
// Merge: use all messages from full fetch, but keep any temp/optimistic messages
|
||||
const existing = get().messages
|
||||
const temp = existing.filter((m) => m.id.startsWith("temp-"))
|
||||
const merged = [...all, ...temp]
|
||||
|
||||
set({
|
||||
messages: merged,
|
||||
parts: { ...allParts, ...Object.fromEntries(temp.map((m) => [m.id, get().parts[m.id] || []])) },
|
||||
loadingMore: false,
|
||||
hasMore: false, // We loaded everything
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to load older messages:", error)
|
||||
set({ loadingMore: false })
|
||||
}
|
||||
},
|
||||
|
||||
createSession: async (title) => {
|
||||
const client = useConnections.getState().client
|
||||
if (!client) {
|
||||
set({ error: "No active connection" })
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await client.session.create({ title })
|
||||
// Don't optimistically add to sessions list — let loadSessions() handle it
|
||||
// to avoid duplicate key errors from race conditions
|
||||
set({
|
||||
currentSession: session,
|
||||
messages: [],
|
||||
parts: {},
|
||||
hasMore: false,
|
||||
loadingMore: false,
|
||||
})
|
||||
return session
|
||||
} catch (error) {
|
||||
set({ error: "Failed to create session" })
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
deleteSession: async (sessionID) => {
|
||||
const client = useConnections.getState().client
|
||||
if (!client) {
|
||||
set({ error: "No active connection" })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await client.session.delete(sessionID)
|
||||
set((state) => ({
|
||||
sessions: state.sessions.filter((s) => s.id !== sessionID),
|
||||
currentSession: state.currentSession?.id === sessionID ? null : state.currentSession,
|
||||
messages: state.currentSession?.id === sessionID ? [] : state.messages,
|
||||
parts: state.currentSession?.id === sessionID ? {} : state.parts,
|
||||
}))
|
||||
} catch (error) {
|
||||
set({ error: "Failed to delete session" })
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (text, model, agent, files) => {
|
||||
const client = clientFor(get().currentSession?.directory)
|
||||
const session = get().currentSession
|
||||
if (!client || !session) {
|
||||
set({ error: "No active session" })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null }))
|
||||
|
||||
// Add user message optimistically
|
||||
const ts = Date.now()
|
||||
const userMessage: Message = {
|
||||
id: `temp-${ts}`,
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
time: { created: ts },
|
||||
model,
|
||||
agent,
|
||||
}
|
||||
const optimisticParts: Part[] = []
|
||||
if (text) {
|
||||
optimisticParts.push({
|
||||
id: `temp-part-text-${ts}`,
|
||||
messageID: userMessage.id,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
}
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i]
|
||||
optimisticParts.push({
|
||||
id: `temp-part-file-${ts}-${i}`,
|
||||
messageID: userMessage.id,
|
||||
type: "file",
|
||||
mime: f.mime,
|
||||
url: f.uri,
|
||||
filename: f.filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: [...state.messages, userMessage],
|
||||
parts: { ...state.parts, [userMessage.id]: optimisticParts },
|
||||
}))
|
||||
|
||||
// Build prompt parts - images are already converted to JPEG with base64 by toJpeg()
|
||||
const promptParts: Array<
|
||||
{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }
|
||||
> = []
|
||||
if (text) {
|
||||
promptParts.push({ type: "text", text })
|
||||
}
|
||||
if (files) {
|
||||
for (const f of files) {
|
||||
const url = f.base64 ? `data:${f.mime};base64,${f.base64}` : f.uri
|
||||
promptParts.push({ type: "file", mime: f.mime, url, filename: f.filename })
|
||||
}
|
||||
}
|
||||
|
||||
// Fire and forget - SSE events will update messages/parts/status in real-time
|
||||
client.session.prompt(session.id, { parts: promptParts, model, agent }).catch((err) => {
|
||||
console.error("Failed to send message:", err)
|
||||
set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } }))
|
||||
get().refreshMessages()
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("[sendMessage] error:", err)
|
||||
set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } }))
|
||||
get().refreshMessages()
|
||||
}
|
||||
},
|
||||
|
||||
abortSession: async () => {
|
||||
const client = clientFor(get().currentSession?.directory)
|
||||
const session = get().currentSession
|
||||
if (!client || !session) return
|
||||
|
||||
try {
|
||||
await client.session.abort(session.id)
|
||||
set((state) => ({ sending: { ...state.sending, [session.id]: false } }))
|
||||
} catch {
|
||||
set({ error: "Failed to abort session" })
|
||||
}
|
||||
},
|
||||
|
||||
refreshMessages: async () => {
|
||||
const client = clientFor(get().currentSession?.directory)
|
||||
const session = get().currentSession
|
||||
if (!client || !session) return
|
||||
|
||||
try {
|
||||
const response = await client.session.messages(session.id)
|
||||
const { messages, parts } = parseMessages(response)
|
||||
set({ messages, parts })
|
||||
} catch (error) {
|
||||
set({ error: "Failed to refresh messages" })
|
||||
}
|
||||
},
|
||||
|
||||
handleEvent: (event) => {
|
||||
const { currentSession } = get()
|
||||
if (!currentSession) return
|
||||
|
||||
const props = (event as any).properties || {}
|
||||
|
||||
switch (event.type) {
|
||||
case "message.updated": {
|
||||
const message = (props.info || props.message) as Message | undefined
|
||||
if (!message || message.sessionID !== currentSession.id) return
|
||||
|
||||
set((state) => {
|
||||
// Remove temp messages when real ones arrive
|
||||
const filtered = state.messages.filter((m) => !m.id.startsWith("temp-") || m.id === message.id)
|
||||
const exists = filtered.some((m) => m.id === message.id)
|
||||
return {
|
||||
messages: exists ? filtered.map((m) => (m.id === message.id ? message : m)) : [...filtered, message],
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
const part = props.part as Part | undefined
|
||||
if (!part) return
|
||||
// Only handle parts for current session
|
||||
if (part.sessionID && part.sessionID !== currentSession.id) return
|
||||
|
||||
set((state) => {
|
||||
const messageParts = state.parts[part.messageID] || []
|
||||
const exists = messageParts.some((p) => p.id === part.id)
|
||||
return {
|
||||
parts: {
|
||||
...state.parts,
|
||||
[part.messageID]: exists
|
||||
? messageParts.map((p) => (p.id === part.id ? part : p))
|
||||
: [...messageParts, part],
|
||||
},
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "message.removed": {
|
||||
const messageID = props.messageID as string
|
||||
if (!messageID) return
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((m) => m.id !== messageID),
|
||||
parts: Object.fromEntries(Object.entries(state.parts).filter(([k]) => k !== messageID)),
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case "session.updated": {
|
||||
const session = (props.info || props) as Session | undefined
|
||||
if (!session?.id) return
|
||||
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((s) => (s.id === session.id ? session : s)),
|
||||
currentSession: state.currentSession?.id === session.id ? session : state.currentSession,
|
||||
}))
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
59
src/stores/settings.ts
Normal file
59
src/stores/settings.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand"
|
||||
import * as SecureStore from "expo-secure-store"
|
||||
import { type Category, defaultPreferences } from "../lib/notifications"
|
||||
|
||||
const SETTINGS_KEY = "opencode_settings"
|
||||
|
||||
interface Settings {
|
||||
pageSize: number
|
||||
notifications: Record<Category, boolean>
|
||||
}
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
pageSize: 25,
|
||||
notifications: { ...defaultPreferences },
|
||||
}
|
||||
|
||||
interface SettingsState extends Settings {
|
||||
loaded: boolean
|
||||
load: () => Promise<void>
|
||||
setPageSize: (size: number) => Promise<void>
|
||||
setNotification: (category: Category, enabled: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
function snapshot(get: () => SettingsState): Settings {
|
||||
return { pageSize: get().pageSize, notifications: get().notifications }
|
||||
}
|
||||
|
||||
async function persist(settings: Settings) {
|
||||
await SecureStore.setItemAsync(SETTINGS_KEY, JSON.stringify(settings))
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsState>((set, get) => ({
|
||||
...DEFAULTS,
|
||||
loaded: false,
|
||||
|
||||
load: async () => {
|
||||
const raw = await SecureStore.getItemAsync(SETTINGS_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<Settings>
|
||||
// Merge stored notifications with defaults so new categories get their default
|
||||
const notifications = { ...DEFAULTS.notifications, ...parsed.notifications }
|
||||
set({ ...DEFAULTS, ...parsed, notifications, loaded: true })
|
||||
return
|
||||
}
|
||||
set({ loaded: true })
|
||||
},
|
||||
|
||||
setPageSize: async (size) => {
|
||||
const clamped = Math.max(10, Math.min(200, size))
|
||||
set({ pageSize: clamped })
|
||||
await persist({ ...snapshot(get), pageSize: clamped })
|
||||
},
|
||||
|
||||
setNotification: async (category, enabled) => {
|
||||
const notifications = { ...get().notifications, [category]: enabled }
|
||||
set({ notifications })
|
||||
await persist({ ...snapshot(get), notifications })
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user