From 09bc35957a1c0e8194efab85c1524c70a0aee0a3 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:07:34 -0400 Subject: [PATCH 1/5] feat: add persistent model state storage with database backend - Create opencode_model_state table for storing user model preferences - Implement database-backed model state management (recent, favorite, variant) - Add atomic JSON file utilities with file locking for concurrent safety - Migrate providers routes from file-based to database storage - Add backfill migration to preserve existing model.json data - Include comprehensive tests for model-state and atomic-json modules - Refactor useSessionAgent hook to simplify agent/model resolution logic - Update getFileApiUrl to use consistent query parameter approach --- backend/package.json | 5 +- .../db/migrations/012-opencode-model-state.ts | 25 ++ backend/src/db/migrations/index.ts | 2 + backend/src/db/model-state.test.ts | 130 ++++++++++ backend/src/db/model-state.ts | 124 +++++++++ backend/src/index.ts | 44 +++- backend/src/routes/providers.test.ts | 145 +++++++++++ backend/src/routes/providers.ts | 95 +++---- backend/src/utils/atomic-json.test.ts | 122 +++++++++ backend/src/utils/atomic-json.ts | 50 ++++ backend/test/mocks/bun-sqlite.ts | 36 +++ backend/vitest.config.ts | 7 + frontend/src/api/files.ts | 33 +-- .../components/navigation/MobileTabBar.tsx | 2 +- frontend/src/hooks/useModelSelection.ts | 6 + frontend/src/hooks/useSessionAgent.test.tsx | 14 +- frontend/src/hooks/useSessionAgent.ts | 92 ++++--- frontend/src/stores/uiStateStore.ts | 8 +- pnpm-lock.yaml | 238 +++++++++++++++++- 19 files changed, 1035 insertions(+), 143 deletions(-) create mode 100644 backend/src/db/migrations/012-opencode-model-state.ts create mode 100644 backend/src/db/model-state.test.ts create mode 100644 backend/src/db/model-state.ts create mode 100644 backend/src/routes/providers.test.ts create mode 100644 backend/src/utils/atomic-json.test.ts create mode 100644 backend/src/utils/atomic-json.ts create mode 100644 backend/test/mocks/bun-sqlite.ts diff --git a/backend/package.json b/backend/package.json index 9589e2f6..ddbfc8b1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,9 @@ "start": "bun src/index.ts", "build": "bun build src/index.ts --outdir=dist --target=bun", "typecheck": "tsc --noEmit", - "test": "vitest", + "test": "bun test src/", + "test:vitest": "vitest", + "test:all": "bun test src/ && vitest test/", "test:ui": "vitest --ui", "test:watch": "vitest --watch", "lint": "eslint . --ext .ts", @@ -37,6 +39,7 @@ "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", + "better-sqlite3": "^12.9.0", "eslint": "^9.39.1", "typescript-eslint": "^8.45.0", "vitest": "^3.2.4" diff --git a/backend/src/db/migrations/012-opencode-model-state.ts b/backend/src/db/migrations/012-opencode-model-state.ts new file mode 100644 index 00000000..4fd2ebe9 --- /dev/null +++ b/backend/src/db/migrations/012-opencode-model-state.ts @@ -0,0 +1,25 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + version: 12, + name: 'opencode-model-state', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS opencode_model_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + recent TEXT NOT NULL DEFAULT '[]', + favorite TEXT NOT NULL DEFAULT '[]', + variant TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + UNIQUE(user_id) + ) + `) + db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)') + }, + down(db) { + db.run('DROP TABLE IF EXISTS opencode_model_state') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index d2ae1e91..6cc1089f 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -10,6 +10,7 @@ import migration008 from './008-schedule-cron-support' import migration009 from './009-repo-source-path' import migration010 from './009-prompt-templates' import migration011 from './011-repo-last-accessed' +import migration012 from './012-opencode-model-state' export const allMigrations: Migration[] = [ migration001, @@ -23,4 +24,5 @@ export const allMigrations: Migration[] = [ migration009, migration010, migration011, + migration012, ] diff --git a/backend/src/db/model-state.test.ts b/backend/src/db/model-state.test.ts new file mode 100644 index 00000000..90f7f3f3 --- /dev/null +++ b/backend/src/db/model-state.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from './migration-runner' +import { allMigrations } from './migrations' +import { + getOpenCodeModelState, + addRecentOpenCodeModel, + toggleFavoriteOpenCodeModel, + setOpenCodeVariant, + MAX_RECENT_MODELS, +} from './model-state' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +describe('model-state', () => { + let db: Database + + beforeEach(() => { + db = createTestDb() + }) + + describe('getOpenCodeModelState', () => { + it('returns empty defaults when no row exists', () => { + const state = getOpenCodeModelState(db) + expect(state).toEqual({ recent: [], favorite: [], variant: {} }) + }) + + it('returns defaults with explicit userId when no row exists', () => { + const state = getOpenCodeModelState(db, 'user123') + expect(state).toEqual({ recent: [], favorite: [], variant: {} }) + }) + }) + + describe('addRecentOpenCodeModel', () => { + it('inserts new state and returns the model in recent[0]', () => { + const model = { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' } + const state = addRecentOpenCodeModel(db, model) + expect(state.recent).toHaveLength(1) + expect(state.recent[0]).toEqual(model) + }) + + it('deduplicates re-selections (same model added twice → length 1, model at index 0)', () => { + const model = { providerID: 'openai', modelID: 'gpt-4o' } + addRecentOpenCodeModel(db, model) + const state = addRecentOpenCodeModel(db, model) + expect(state.recent).toHaveLength(1) + expect(state.recent[0]).toEqual(model) + }) + + it('caps at MAX_RECENT_MODELS (insert 12 distinct, expect 10)', () => { + for (let i = 0; i < 12; i++) { + addRecentOpenCodeModel(db, { providerID: `provider-${i}`, modelID: `model-${i}` }) + } + const state = getOpenCodeModelState(db) + expect(state.recent).toHaveLength(MAX_RECENT_MODELS) + expect(state.recent[0]).toEqual({ providerID: 'provider-11', modelID: 'model-11' }) + }) + }) + + describe('toggleFavoriteOpenCodeModel', () => { + it('adds when missing', () => { + const model = { providerID: 'anthropic', modelID: 'claude' } + const state = toggleFavoriteOpenCodeModel(db, model) + expect(state.favorite).toHaveLength(1) + expect(state.favorite[0]).toEqual(model) + }) + + it('removes when present', () => { + const model = { providerID: 'openai', modelID: 'gpt-4' } + toggleFavoriteOpenCodeModel(db, model) + const state = toggleFavoriteOpenCodeModel(db, model) + expect(state.favorite).toHaveLength(0) + }) + }) + + describe('setOpenCodeVariant', () => { + it('adds variant entry', () => { + const state = setOpenCodeVariant(db, 'key1', 'variant1') + expect(state.variant.key1).toBe('variant1') + }) + + it('updates variant entry', () => { + setOpenCodeVariant(db, 'key1', 'variant1') + const state = setOpenCodeVariant(db, 'key1', 'variant2') + expect(state.variant.key1).toBe('variant2') + }) + + it('deletes variant when undefined', () => { + setOpenCodeVariant(db, 'key1', 'variant1') + const state = setOpenCodeVariant(db, 'key1', undefined) + expect(state.variant.key1).toBeUndefined() + }) + }) + + it('corrupt JSON in recent column → getOpenCodeModelState returns [] for recent, preserves valid favorite', () => { + const now = Date.now() + db.prepare(` + INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) + VALUES(?,?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET recent=excluded.recent, favorite=excluded.favorite, variant=excluded.variant, updated_at=excluded.updated_at + `).run('default', '{ invalid json }', JSON.stringify([{ providerID: 'test', modelID: 'test' }]), '{}', now) + + const state = getOpenCodeModelState(db) + expect(state.recent).toEqual([]) + expect(state.favorite).toHaveLength(1) + expect(state.favorite[0]).toEqual({ providerID: 'test', modelID: 'test' }) + }) + + it('50 concurrent addRecentOpenCodeModel calls → final recent.length <= MAX_RECENT_MODELS, no exceptions, all entries unique', async () => { + const db = createTestDb() + const numOps = 50 + + const operations = Array.from({ length: numOps }, (_, i) => + addRecentOpenCodeModel(db, { providerID: `provider-${i}`, modelID: `model-${i}` }), + ) + + await Promise.all(operations) + const finalState = getOpenCodeModelState(db) + + expect(finalState.recent.length).toBeLessThanOrEqual(MAX_RECENT_MODELS) + expect(finalState.recent.length).toBeGreaterThan(0) + + const uniqueKeys = new Set(finalState.recent.map((m) => `${m.providerID}/${m.modelID}`)) + expect(uniqueKeys.size).toBe(finalState.recent.length) + }) +}) diff --git a/backend/src/db/model-state.ts b/backend/src/db/model-state.ts new file mode 100644 index 00000000..97ed4058 --- /dev/null +++ b/backend/src/db/model-state.ts @@ -0,0 +1,124 @@ +import { Database } from 'bun:sqlite' +import { logger } from '../utils/logger' + +export interface ModelSelectionRecord { + providerID: string + modelID: string +} + +export interface OpenCodeModelStateRecord { + recent: ModelSelectionRecord[] + favorite: ModelSelectionRecord[] + variant: Record +} + +export const MAX_RECENT_MODELS = 10 + +const EMPTY_STATE: OpenCodeModelStateRecord = { recent: [], favorite: [], variant: {} } + +function parseJsonSafe(json: string, fallback: T): T { + try { + return JSON.parse(json) as T + } catch (error) { + logger.warn(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`) + return fallback + } +} + +export function getOpenCodeModelState(db: Database, userId = 'default'): OpenCodeModelStateRecord { + const row = db.prepare('SELECT recent, favorite, variant FROM opencode_model_state WHERE user_id = ?').get(userId) as + | { recent: string; favorite: string; variant: string } + | undefined + + if (!row) { + return EMPTY_STATE + } + + const recent = parseJsonSafe(row.recent, []) + const favorite = parseJsonSafe(row.favorite, []) + const variant = parseJsonSafe>(row.variant, {}) + + return { recent, favorite, variant } +} + +export function addRecentOpenCodeModel( + db: Database, + model: ModelSelectionRecord, + userId = 'default', +): OpenCodeModelStateRecord { + const insertMany = db.transaction(() => { + const current = getOpenCodeModelState(db, userId) + const deduped = [model, ...current.recent.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID)] + const sliced = deduped.slice(0, MAX_RECENT_MODELS) + const now = Date.now() + + db.prepare(` + INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) + VALUES(?,?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET recent=excluded.recent, updated_at=excluded.updated_at + `).run(userId, JSON.stringify(sliced), JSON.stringify(current.favorite), JSON.stringify(current.variant), now) + + return { recent: sliced, favorite: current.favorite, variant: current.variant } + }) + + return insertMany() +} + +export function toggleFavoriteOpenCodeModel( + db: Database, + model: ModelSelectionRecord, + userId = 'default', +): OpenCodeModelStateRecord { + const toggle = db.transaction(() => { + const current = getOpenCodeModelState(db, userId) + const exists = current.favorite.some( + m => m.providerID === model.providerID && m.modelID === model.modelID, + ) + + const updated = exists + ? current.favorite.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID) + : [...current.favorite, model] + + const now = Date.now() + + db.prepare(` + INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) + VALUES(?,?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET favorite=excluded.favorite, updated_at=excluded.updated_at + `).run(userId, JSON.stringify(current.recent), JSON.stringify(updated), JSON.stringify(current.variant), now) + + return { recent: current.recent, favorite: updated, variant: current.variant } + }) + + return toggle() +} + +export function setOpenCodeVariant( + db: Database, + key: string, + variant: string | undefined, + userId = 'default', +): OpenCodeModelStateRecord { + const setVariant = db.transaction(() => { + const current = getOpenCodeModelState(db, userId) + const updated = { ...current.variant } + + if (variant === undefined) { + delete updated[key] + } else { + updated[key] = variant + } + + const now = Date.now() + + db.prepare(` + INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) + VALUES(?,?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET variant=excluded.variant, updated_at=excluded.updated_at + `).run(userId, JSON.stringify(current.recent), JSON.stringify(current.favorite), JSON.stringify(updated), now) + + return { recent: current.recent, favorite: current.favorite, variant: updated } + }) + + return setVariant() +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 6c06b282..c05867c5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -47,6 +47,11 @@ import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode import { OpenCodeSupervisor } from './services/opencode-supervisor' import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' import { parse as parseJsonc } from 'jsonc-parser' +import { getModelStatePath, ModelStateSchema } from './routes/providers' +import { readJsonSafe } from './utils/atomic-json' +import { + type OpenCodeModelStateRecord, +} from './db/model-state' import { logger } from './utils/logger' import { @@ -158,6 +163,42 @@ async function ensureDefaultConfigExists(): Promise { logger.info('Created minimal seed config') } +async function backfillOpenCodeModelStateFromFile(): Promise { + try { + const modelStatePath = getModelStatePath() + const fileState = await readJsonSafe(modelStatePath, null) + + if (!fileState) { + return + } + + const existingRow = db.prepare('SELECT 1 FROM opencode_model_state WHERE user_id = ?').get('default') + if (existingRow) { + return + } + + const validated = ModelStateSchema.safeParse(fileState) + if (!validated.success) { + logger.warn('Model state file has invalid structure, skipping backfill', validated.error) + return + } + + db.prepare( + 'INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) VALUES(?,?,?,?,?)' + ).run( + 'default', + JSON.stringify(validated.data.recent), + JSON.stringify(validated.data.favorite), + JSON.stringify(validated.data.variant), + Date.now() + ) + + logger.info('Backfilled OpenCode model state from model.json to database') + } catch (error) { + logger.warn('Failed to backfill OpenCode model state from file:', error) + } +} + async function ensureHomeStateImported(): Promise { try { const status = await getOpenCodeImportStatus() @@ -204,6 +245,7 @@ try { await cleanupExpiredCache() await ensureDefaultConfigExists() + await backfillOpenCodeModelStateFromFile() await ensureHomeStateImported() await ensureDefaultAgentsMdExists() @@ -271,7 +313,7 @@ protectedApi.use('/*', requireAuth) protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeSupervisor)) protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeSupervisor)) protectedApi.route('/files', createFileRoutes()) -protectedApi.route('/providers', createProvidersRoutes(openCodeSupervisor)) +protectedApi.route('/providers', createProvidersRoutes(db, openCodeSupervisor)) protectedApi.route('/oauth', createOAuthRoutes(openCodeSupervisor)) protectedApi.route('/tts', createTTSRoutes(db)) protectedApi.route('/stt', createSTTRoutes(db)) diff --git a/backend/src/routes/providers.test.ts b/backend/src/routes/providers.test.ts new file mode 100644 index 00000000..4fa5618a --- /dev/null +++ b/backend/src/routes/providers.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { migrate } from '../db/migration-runner' +import { allMigrations } from '../db/migrations' +import { createProvidersRoutes, getModelStatePath } from './providers' +import { join } from 'node:path' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +function createTestApp(db: Database): Hono { + const app = new Hono() + app.route('/providers', createProvidersRoutes(db, undefined)) + return app +} + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +describe('providers routes', () => { + let db: Database + let app: Hono + let tmpDir: string + let originalWorkspacePath: string | undefined + + beforeEach(async () => { + db = createTestDb() + app = createTestApp(db) + tmpDir = await mkdtemp(join(tmpdir(), 'providers-test-')) + originalWorkspacePath = process.env.OPENCODE_WORKSPACE_PATH + process.env.OPENCODE_WORKSPACE_PATH = tmpDir + }) + + afterEach(async () => { + if (originalWorkspacePath) { + process.env.OPENCODE_WORKSPACE_PATH = originalWorkspacePath + } else { + delete process.env.OPENCODE_WORKSPACE_PATH + } + await rm(tmpDir, { recursive: true, force: true }) + }) + + describe('GET /model-state', () => { + it('on empty DB returns defaults', async () => { + const res = await app.request('/providers/model-state') + expect(res.status).toBe(200) + const data = (await res.json()) as { recent: unknown[]; favorite: unknown[]; variant: Record } + expect(data).toEqual({ recent: [], favorite: [], variant: {} }) + }) + }) + + describe('POST /model-state', () => { + it('with recent returns 200 with recent[0] set', async () => { + const res = await app.request('/providers/model-state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recent: { providerID: 'anthropic', modelID: 'claude' } }), + }) + expect(res.status).toBe(200) + const data = (await res.json()) as { recent: Array<{ providerID: string; modelID: string }> } + expect(data.recent).toHaveLength(1) + expect(data.recent[0]).toEqual({ providerID: 'anthropic', modelID: 'claude' }) + }) + + it('with favorite toggles favorite (add then remove)', async () => { + const body = { favorite: { providerID: 'openai', modelID: 'gpt-4' } } + + const res1 = await app.request('/providers/model-state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + expect(res1.status).toBe(200) + const data1 = (await res1.json()) as { favorite: Array<{ providerID: string; modelID: string }> } + expect(data1.favorite).toHaveLength(1) + + const res2 = await app.request('/providers/model-state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + expect(res2.status).toBe(200) + const data2 = (await res2.json()) as { favorite: Array<{ providerID: string; modelID: string }> } + expect(data2.favorite).toHaveLength(0) + }) + + it('with invalid body returns 400', async () => { + const res = await app.request('/providers/model-state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invalid: 'data' }), + }) + expect(res.status).toBe(400) + const data = (await res.json()) as { error: string } + expect(data.error).toBe('Invalid request data') + }) + + it('with corrupt model.json on disk still returns 200 and overwrites with valid JSON', async () => { + const modelStatePath = getModelStatePath() + await writeFile(modelStatePath, '{ invalid json content }', 'utf8') + + const res = await app.request('/providers/model-state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recent: { providerID: 'test', modelID: 'test' } }), + }) + + expect(res.status).toBe(200) + const data = (await res.json()) as { recent: Array<{ providerID: string; modelID: string }> } + expect(data.recent).toHaveLength(1) + + const fileContent = await Bun.file(modelStatePath).text() + const parsed = JSON.parse(fileContent) as { recent: unknown[] } + expect(parsed.recent).toHaveLength(1) + }) + + it('20 concurrent POST calls all return 200, final recent is valid and bounded', async () => { + const numOps = 20 + + const requests = Array.from({ length: numOps }, (_, i) => + app.request('/providers/model-state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recent: { providerID: `provider-${i}`, modelID: `model-${i}` } }), + }), + ) + + const responses = await Promise.all(requests) + responses.forEach((res) => { + expect(res.status).toBe(200) + }) + + const finalRes = await app.request('/providers/model-state') + const finalData = (await finalRes.json()) as { recent: Array<{ providerID: string; modelID: string }> } + expect(finalData.recent.length).toBeLessThanOrEqual(10) + expect(finalData.recent.length).toBeGreaterThan(0) + + const uniqueKeys = new Set(finalData.recent.map((m) => `${m.providerID}/${m.modelID}`)) + expect(uniqueKeys.size).toBe(finalData.recent.length) + }) + }) +}) diff --git a/backend/src/routes/providers.ts b/backend/src/routes/providers.ts index 0b3266fa..8b851e24 100644 --- a/backend/src/routes/providers.ts +++ b/backend/src/routes/providers.ts @@ -7,15 +7,22 @@ import { logger } from '../utils/logger' import { setOpenCodeAuth, deleteOpenCodeAuth } from '../services/proxy' import { opencodeServerManager } from '../services/opencode-single-server' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' -import { fileExists, readFileContent, writeFileContent } from '../services/file-operations' +import type { Database } from 'bun:sqlite' import { getWorkspacePath } from '@opencode-manager/shared/config/env' - -const ModelSelectionSchema = z.object({ +import { + addRecentOpenCodeModel, + getOpenCodeModelState as readModelStateFromDb, + toggleFavoriteOpenCodeModel, + type OpenCodeModelStateRecord, +} from '../db/model-state' +import { writeJsonAtomic, withFileLock } from '../utils/atomic-json' + +export const ModelSelectionSchema = z.object({ providerID: z.string().min(1), modelID: z.string().min(1), }) -const ModelStateSchema = z.object({ +export const ModelStateSchema = z.object({ recent: z.array(ModelSelectionSchema).default([]), favorite: z.array(ModelSelectionSchema).default([]), variant: z.record(z.string(), z.string().optional()).default({}), @@ -24,55 +31,25 @@ const ModelStateSchema = z.object({ const UpdateModelStateSchema = z.object({ recent: ModelSelectionSchema.optional(), favorite: ModelSelectionSchema.optional(), -}) - -type ModelSelection = z.infer -type ModelState = z.infer +}).strict() -const MAX_RECENT_MODELS = 10 - -function getModelStatePath(): string { +export function getModelStatePath(): string { return path.join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'model.json') } -async function readModelState(): Promise { +async function mirrorModelStateToFile(state: OpenCodeModelStateRecord): Promise { const modelStatePath = getModelStatePath() - if (!await fileExists(modelStatePath)) { - return { recent: [], favorite: [], variant: {} } + try { + await withFileLock(modelStatePath, async () => { + await writeJsonAtomic(modelStatePath, { + recent: state.recent, + favorite: state.favorite, + variant: state.variant, + }) + }) + } catch (error) { + logger.warn(`Failed to mirror model state to file ${modelStatePath}:`, error) } - - return ModelStateSchema.parse(JSON.parse(await readFileContent(modelStatePath))) -} - -function uniqueModels(models: ModelSelection[]): ModelSelection[] { - const seen = new Set() - return models.filter((model) => { - const key = `${model.providerID}/${model.modelID}` - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) -} - -async function addRecentModel(model: ModelSelection): Promise { - const state = await readModelState() - const recent = uniqueModels([model, ...state.recent]).slice(0, MAX_RECENT_MODELS) - const nextState = { ...state, recent } - await writeFileContent(getModelStatePath(), JSON.stringify(nextState, null, 2)) - return nextState -} - -async function toggleFavoriteModel(model: ModelSelection): Promise { - const state = await readModelState() - const exists = state.favorite.some((favorite) => favorite.providerID === model.providerID && favorite.modelID === model.modelID) - const favorite = exists - ? state.favorite.filter((favorite) => favorite.providerID !== model.providerID || favorite.modelID !== model.modelID) - : uniqueModels([model, ...state.favorite]) - const nextState = { ...state, favorite } - await writeFileContent(getModelStatePath(), JSON.stringify(nextState, null, 2)) - return nextState } async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Promise { @@ -84,15 +61,16 @@ async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Pr await opencodeServerManager.reloadConfig() } -export function createProvidersRoutes(openCodeSupervisor?: OpenCodeSupervisor) { +export function createProvidersRoutes(db: Database, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() const authService = new AuthService() app.get('/model-state', async (c) => { try { - return c.json(await readModelState()) + const state = readModelStateFromDb(db) + return c.json(state) } catch (error) { - logger.error('Failed to read OpenCode model state:', error) + logger.error('Failed to read OpenCode model state from DB:', error) return c.json({ recent: [], favorite: [], variant: {} }) } }) @@ -101,13 +79,20 @@ export function createProvidersRoutes(openCodeSupervisor?: OpenCodeSupervisor) { try { const body = await c.req.json() const validated = UpdateModelStateSchema.parse(body) + + let nextState: OpenCodeModelStateRecord + if (validated.favorite) { - return c.json(await toggleFavoriteModel(validated.favorite)) - } - if (!validated.recent) { - return c.json(await readModelState()) + nextState = toggleFavoriteOpenCodeModel(db, validated.favorite) + } else if (validated.recent) { + nextState = addRecentOpenCodeModel(db, validated.recent) + } else { + nextState = readModelStateFromDb(db) } - return c.json(await addRecentModel(validated.recent)) + + await mirrorModelStateToFile(nextState) + + return c.json(nextState) } catch (error) { logger.error('Failed to update OpenCode model state:', error) if (error instanceof z.ZodError) { diff --git a/backend/src/utils/atomic-json.test.ts b/backend/src/utils/atomic-json.test.ts new file mode 100644 index 00000000..50864e2d --- /dev/null +++ b/backend/src/utils/atomic-json.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { readJsonSafe, writeJsonAtomic, withFileLock } from './atomic-json' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +describe('atomic-json', () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'atomic-json-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + describe('readJsonSafe', () => { + it('returns fallback when file does not exist', async () => { + const fallback = { foo: 'bar' } + const result = await readJsonSafe(join(tmpDir, 'nonexistent.json'), fallback) + expect(result).toEqual(fallback) + }) + + it('returns fallback when file contains invalid JSON and logs a warning', async () => { + const filePath = join(tmpDir, 'invalid.json') + await Bun.write(filePath, '{ invalid json }') + const fallback = { foo: 'bar' } + const result = await readJsonSafe(filePath, fallback) + expect(result).toEqual(fallback) + }) + + it('returns parsed value when file contains valid JSON', async () => { + const filePath = join(tmpDir, 'valid.json') + const data = { foo: 'bar', nested: { value: 42 } } + await Bun.write(filePath, JSON.stringify(data)) + const result = await readJsonSafe(filePath, { fallback: true }) + expect(result).toEqual(data) + }) + }) + + describe('writeJsonAtomic', () => { + it('writes valid JSON readable by JSON.parse', async () => { + const filePath = join(tmpDir, 'output.json') + const data = { test: 'value', number: 123 } + await writeJsonAtomic(filePath, data) + const content = await Bun.file(filePath).text() + const parsed = JSON.parse(content) + expect(parsed).toEqual(data) + }) + + it('does not leave .tmp.* files on success', async () => { + const filePath = join(tmpDir, 'output.json') + await writeJsonAtomic(filePath, { test: 'value' }) + const { readdir } = await import('node:fs/promises') + const files = await readdir(tmpDir) + const tmpFiles = files.filter((f) => f.includes('.tmp.')) + expect(tmpFiles.length).toBe(0) + }) + + it('round-trips a complex object', async () => { + const filePath = join(tmpDir, 'roundtrip.json') + const data = { + array: [1, 2, 3], + nested: { a: 'b', c: { d: 'e' } }, + nullish: null, + bool: true, + } + await writeJsonAtomic(filePath, data) + const result = await readJsonSafe(filePath, null) + expect(result).toEqual(data) + }) + }) + + describe('withFileLock', () => { + it('serializes two concurrent calls', async () => { + const filePath = join(tmpDir, 'locked.json') + const executionOrder: number[] = [] + + const task1 = withFileLock(filePath, async () => { + executionOrder.push(1) + await new Promise((resolve) => setTimeout(resolve, 50)) + executionOrder.push(2) + return 'task1' + }) + + const task2 = withFileLock(filePath, async () => { + executionOrder.push(3) + await new Promise((resolve) => setTimeout(resolve, 50)) + executionOrder.push(4) + return 'task2' + }) + + const [result1, result2] = await Promise.all([task1, task2]) + + expect(result1).toBe('task1') + expect(result2).toBe('task2') + expect(executionOrder).toEqual([1, 2, 3, 4]) + }) + + it('concurrent stress test: 50 writes then reads', async () => { + const filePath = join(tmpDir, 'stress.json') + const numOps = 50 + + const operations = Array.from({ length: numOps }, (_, i) => + withFileLock(filePath, async () => { + const data = { value: i, timestamp: Date.now() } + await writeJsonAtomic(filePath, data) + const read = await readJsonSafe(filePath, null) + return { written: data, read } + }), + ) + + await Promise.all(operations) + const finalRead = await readJsonSafe(filePath, null) + + expect(finalRead).toEqual({ value: numOps - 1, timestamp: expect.any(Number) }) + expect(typeof finalRead).toBe('object') + expect(finalRead).not.toBeNull() + }) + }) +}) diff --git a/backend/src/utils/atomic-json.ts b/backend/src/utils/atomic-json.ts new file mode 100644 index 00000000..9e454241 --- /dev/null +++ b/backend/src/utils/atomic-json.ts @@ -0,0 +1,50 @@ +import { promises as fs } from 'node:fs' +import { randomBytes } from 'node:crypto' +import { logger } from './logger' + +const fileLockPromises = new Map>() + +export async function readJsonSafe(filePath: string, fallback: T): Promise { + try { + const content = await fs.readFile(filePath, 'utf8') + return JSON.parse(content) as T + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return fallback + } + logger.warn(`Failed to read or parse JSON from ${filePath}: ${error instanceof Error ? error.message : String(error)}`) + return fallback + } +} + +export async function writeJsonAtomic(filePath: string, data: unknown): Promise { + const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}` + try { + await fs.mkdir(filePath.substring(0, filePath.lastIndexOf('/')), { recursive: true }) + await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8') + await fs.rename(tmpPath, filePath) + } catch (error) { + await fs.unlink(tmpPath).catch(() => undefined) + throw error + } +} + +export async function withFileLock(filePath: string, fn: () => Promise): Promise { + const absolutePath = filePath + const previousLock = fileLockPromises.get(absolutePath) + + const executeWithLock = async (): Promise => { + try { + return await fn() + } finally { + if (fileLockPromises.get(absolutePath) === newLock) { + fileLockPromises.delete(absolutePath) + } + } + } + + const newLock = previousLock ? previousLock.then(executeWithLock) : executeWithLock() + fileLockPromises.set(absolutePath, newLock) + + return newLock as Promise +} diff --git a/backend/test/mocks/bun-sqlite.ts b/backend/test/mocks/bun-sqlite.ts new file mode 100644 index 00000000..d31461a9 --- /dev/null +++ b/backend/test/mocks/bun-sqlite.ts @@ -0,0 +1,36 @@ +import DatabaseImpl from 'better-sqlite3' +import type { Database as BetterSqlite3Database } from 'better-sqlite3' + +// Adapter to make better-sqlite3 compatible with bun:sqlite API +export class Database { + private db: BetterSqlite3Database + + constructor(path: string) { + this.db = new DatabaseImpl(path) + } + + prepare(sql: string) { + const stmt = this.db.prepare(sql) + return { + run: (...params: unknown[]) => stmt.run(...params), + get: (...params: unknown[]) => stmt.get(...params), + all: (...params: unknown[]) => stmt.all(...params), + } + } + + exec(sql: string) { + this.db.exec(sql) + } + + run(sql: string, ...params: unknown[]) { + return this.db.prepare(sql).run(...params) + } + + transaction void>(fn: T) { + return this.db.transaction(fn) + } + + close() { + this.db.close() + } +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 2425b4fa..33fadcd0 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ globals: true, environment: 'node', setupFiles: ['./test/setup.ts'], + include: ['test/**/*.{test,spec}.{ts,tsx}', 'src/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], coverage: { provider: 'v8', reporter: ['text', 'html'], @@ -17,4 +19,9 @@ export default defineConfig({ WORKSPACE_PATH: '/tmp/test-workspace', }, }, + resolve: { + alias: { + 'bun:sqlite': './test/mocks/bun-sqlite.ts', + }, + }, }) diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 335776f7..7337d9f3 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -8,10 +8,6 @@ interface FileApiUrlOptions { params?: Record } -function pathRequiresQuery(path: string): boolean { - return path.split('/').some(segment => segment === '.' || segment === '..') -} - export function getFileApiUrl(path: string, options: FileApiUrlOptions = {}): string { const searchParams = new URLSearchParams() Object.entries(options.params ?? {}).forEach(([key, value]) => { @@ -20,17 +16,10 @@ export function getFileApiUrl(path: string, options: FileApiUrlOptions = {}): st } }) - const routePath = options.route ? `/${options.route}` : '' - - if (pathRequiresQuery(path)) { - searchParams.set('path', path) - const query = searchParams.toString() - return `${API_BASE_URL}/api/files${routePath}${query ? `?${query}` : ''}` - } - - const encodedPath = path.split('/').map(encodeURIComponent).join('/') + searchParams.set('path', path) const query = searchParams.toString() - return `${API_BASE_URL}/api/files/${encodedPath}${routePath}${query ? `?${query}` : ''}` + const routePath = options.route ? `/${options.route}` : '' + return `${API_BASE_URL}/api/files${routePath}${query ? `?${query}` : ''}` } async function fetchFile(path: string): Promise { @@ -51,14 +40,6 @@ export async function fetchFileRange(path: string, startLine: number, endLine: n }) } -export async function applyFilePatches(path: string, patches: PatchOperation[]): Promise<{ success: boolean; totalLines: number }> { - return fetchWrapper(getFileApiUrl(path, { route: 'patches' }), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ patches }), - }) -} - export async function getIgnoredPaths(path: string): Promise<{ ignoredPaths: string[] }> { return fetchWrapper(getFileApiUrl(path, { route: 'ignored-paths' })) } @@ -88,3 +69,11 @@ export async function downloadDirectoryAsZip(path: string, options?: DownloadOpt document.body.removeChild(a) window.URL.revokeObjectURL(urlObj) } + +export async function applyFilePatches(path: string, patches: PatchOperation[]): Promise<{ success: boolean; totalLines: number }> { + const url = getFileApiUrl(path) + return fetchWrapper(url, { + method: 'PATCH', + body: JSON.stringify({ patches }), + }) +} diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index 1952c027..50b1f8ab 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -38,7 +38,7 @@ function getMobileTabRouteState(pathname: string): MobileTabRouteState { const repoId = repoMatch?.[1] ?? null const repoSection = repoMatch?.[2] - if (pathname === '/' || pathname === '/schedules') { + if (pathname === '/' || pathname === '/schedules' || pathname === '/assistant') { return { mode: 'global', isInsideRepo: false, repoId: null } } diff --git a/frontend/src/hooks/useModelSelection.ts b/frontend/src/hooks/useModelSelection.ts index c58bd62b..3b1b4c94 100644 --- a/frontend/src/hooks/useModelSelection.ts +++ b/frontend/src/hooks/useModelSelection.ts @@ -58,6 +58,9 @@ export function useModelSelection( queryClient.setQueryData([...modelStateQueryKey, opcodeUrl, directory], state) queryClient.invalidateQueries({ queryKey: [...modelStateQueryKey, opcodeUrl, directory] }) }, + onError: (error) => { + console.error('Failed to sync recent model to backend', error) + }, }) const updateFavoriteModel = useMutation({ @@ -67,6 +70,9 @@ export function useModelSelection( queryClient.setQueryData([...modelStateQueryKey, opcodeUrl, directory], state) queryClient.invalidateQueries({ queryKey: [...modelStateQueryKey, opcodeUrl, directory] }) }, + onError: (error) => { + console.error('Failed to toggle favorite model on backend', error) + }, }) const defaultModelString = providersData?.providers diff --git a/frontend/src/hooks/useSessionAgent.test.tsx b/frontend/src/hooks/useSessionAgent.test.tsx index 5919f3ac..21270608 100644 --- a/frontend/src/hooks/useSessionAgent.test.tsx +++ b/frontend/src/hooks/useSessionAgent.test.tsx @@ -1,9 +1,8 @@ import { renderHook, waitFor } from '@testing-library/react' import { describe, it, expect, beforeEach, vi } from 'vitest' -import { resolveDefaultSessionAgent } from './useSessionAgent' -import { useSessionAgent } from './useSessionAgent' +import { useSessionAgent, resolveDefaultSessionAgent } from './useSessionAgent' import { useMessages, useConfig, useAgents } from './useOpenCode' -import { useSessionAgentStore } from '@/stores/sessionAgentStore' +import { useSessionAgentStore } from '../stores/sessionAgentStore' const sessionAgentStoreMock = vi.hoisted(() => { const state = { @@ -37,6 +36,11 @@ vi.mock('@/stores/sessionAgentStore', () => ({ useSessionAgentStore: sessionAgentStoreMock.store, })) +beforeEach(() => { + vi.clearAllMocks() + sessionAgentStoreMock.store.setState({ agents: {} }) +}) + describe('resolveDefaultSessionAgent', () => { it('returns config.default_agent when present and agents not loaded', () => { const result = resolveDefaultSessionAgent('code', undefined, false) @@ -107,7 +111,7 @@ describe('useSessionAgent', () => { vi.mocked(useMessages).mockReturnValue({ data: [], isLoading: false, - } as ReturnType) + } as unknown as ReturnType) vi.mocked(useConfig).mockReturnValue({ data: { default_agent: 'code' }, } as ReturnType) @@ -163,7 +167,7 @@ describe('useSessionAgent', () => { vi.mocked(useMessages).mockReturnValue({ data: [], isLoading: false, - } as ReturnType) + } as unknown as ReturnType) vi.mocked(useConfig).mockReturnValue({ data: { default_agent: 'code' }, } as ReturnType) diff --git a/frontend/src/hooks/useSessionAgent.ts b/frontend/src/hooks/useSessionAgent.ts index 127963f6..3c333baf 100644 --- a/frontend/src/hooks/useSessionAgent.ts +++ b/frontend/src/hooks/useSessionAgent.ts @@ -62,35 +62,6 @@ export function useSessionAgent( ) const result = useMemo(() => { - if (storedAgent && messages && messages.length > 0) { - let model: { providerID: string; modelID: string } | undefined - let variant: string | undefined - - for (let i = messages.length - 1; i >= 0; i--) { - const msgWithParts = messages[i] - if (msgWithParts.info.role === 'user') { - const userInfo = msgWithParts.info as UserMessage - model = userInfo.model - variant = userInfo.variant - break - } - } - - const prev = prevRef.current - if ( - prev.agent === storedAgent && - prev.variant === variant && - prev.model?.providerID === model?.providerID && - prev.model?.modelID === model?.modelID - ) { - return { ...prev, fromMessage: false } - } - - const next: SessionAgentResult = { agent: storedAgent, model, variant, fromMessage: false } - prevRef.current = next - return next - } - if (messagesLoading) { return { agent: defaultAgent, model: undefined, variant: undefined, fromMessage: false } } @@ -99,33 +70,60 @@ export function useSessionAgent( return { agent: defaultAgent, model: undefined, variant: undefined, fromMessage: false } } + let latestAgent: string | undefined + let latestModel: { providerID: string; modelID: string } | undefined + let latestVariant: string | undefined + for (let i = messages.length - 1; i >= 0; i--) { const msgWithParts = messages[i] if (msgWithParts.info.role === 'user') { const userInfo = msgWithParts.info as UserMessage if (userInfo.agent) { - const prev = prevRef.current - if ( - prev.agent === userInfo.agent && - prev.variant === userInfo.variant && - prev.model?.providerID === userInfo.model?.providerID && - prev.model?.modelID === userInfo.model?.modelID - ) { - return { ...prev, fromMessage: true } - } - - const next: SessionAgentResult = { - agent: userInfo.agent, - model: userInfo.model, - variant: userInfo.variant, - fromMessage: true, - } - prevRef.current = next - return next + latestAgent = userInfo.agent + latestModel = userInfo.model + latestVariant = userInfo.variant + break } } } + if (latestAgent) { + const prev = prevRef.current + if ( + prev.agent === latestAgent && + prev.variant === latestVariant && + prev.model?.providerID === latestModel?.providerID && + prev.model?.modelID === latestModel?.modelID + ) { + return { ...prev, fromMessage: true } + } + + const next: SessionAgentResult = { + agent: latestAgent, + model: latestModel, + variant: latestVariant, + fromMessage: true, + } + prevRef.current = next + return next + } + + if (storedAgent) { + const prev = prevRef.current + if ( + prev.agent === storedAgent && + prev.variant === latestVariant && + prev.model?.providerID === latestModel?.providerID && + prev.model?.modelID === latestModel?.modelID + ) { + return { ...prev, fromMessage: false } + } + + const next: SessionAgentResult = { agent: storedAgent, model: latestModel, variant: latestVariant, fromMessage: false } + prevRef.current = next + return next + } + return { agent: defaultAgent, model: undefined, variant: undefined, fromMessage: false } }, [messages, messagesLoading, storedAgent, defaultAgent]) diff --git a/frontend/src/stores/uiStateStore.ts b/frontend/src/stores/uiStateStore.ts index a26c662e..933265a3 100644 --- a/frontend/src/stores/uiStateStore.ts +++ b/frontend/src/stores/uiStateStore.ts @@ -5,26 +5,26 @@ type CommandType = components['schemas']['Command'] interface UIStateStore { isEditingMessage: boolean + activePromptFileBasePath: string | null pendingPromptCommand: { id: number; command: CommandType } | null pendingPromptFile: { id: number; path: string } | null - activePromptFileBasePath: string | null setIsEditingMessage: (isEditing: boolean) => void + setActivePromptFileBasePath: (basePath: string | null) => void selectPromptCommand: (command: CommandType) => void clearPendingPromptCommand: () => void selectPromptFile: (path: string) => void clearPendingPromptFile: () => void - setActivePromptFileBasePath: (basePath: string | null) => void } export const useUIState = create((set) => ({ isEditingMessage: false, + activePromptFileBasePath: null, pendingPromptCommand: null, pendingPromptFile: null, - activePromptFileBasePath: null, setIsEditingMessage: (isEditing: boolean) => set({ isEditingMessage: isEditing }), + setActivePromptFileBasePath: (basePath: string | null) => set({ activePromptFileBasePath: basePath }), selectPromptCommand: (command: CommandType) => set({ pendingPromptCommand: { id: Date.now(), command } }), clearPendingPromptCommand: () => set({ pendingPromptCommand: null }), selectPromptFile: (path: string) => set({ pendingPromptFile: { id: Date.now(), path } }), clearPendingPromptFile: () => set({ pendingPromptFile: null }), - setActivePromptFileBasePath: (basePath: string | null) => set({ activePromptFileBasePath: basePath }), })) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9475cf95..dba0c015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,7 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.4.17 - version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) + version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) '@hono/node-server': specifier: ^1.19.5 version: 1.19.7(hono@4.11.7) @@ -38,7 +38,7 @@ importers: version: 7.0.1 better-auth: specifier: ^1.4.17 - version: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + version: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) croner: specifier: ^10.0.1 version: 10.0.1 @@ -88,6 +88,9 @@ importers: '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) + better-sqlite3: + specifier: ^12.9.0 + version: 12.9.0 eslint: specifier: ^9.39.1 version: 9.39.2(jiti@2.6.1) @@ -102,7 +105,7 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.4.17 - version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) + version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -156,7 +159,7 @@ importers: version: 5.90.16(react@19.2.3) better-auth: specifier: ^1.4.17 - version: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + version: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2038,9 +2041,19 @@ packages: zod: optional: true + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -2069,6 +2082,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2124,6 +2140,9 @@ packages: chevrotain@11.0.3: resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2412,10 +2431,18 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2474,6 +2501,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.4: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} @@ -2580,6 +2610,10 @@ packages: resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} engines: {node: '>=20.0.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2618,6 +2652,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2636,6 +2673,9 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2653,6 +2693,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2773,6 +2816,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3211,6 +3257,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -3240,6 +3290,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -3262,9 +3315,16 @@ packages: resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} engines: {node: ^20.0.0 || >=22.0.0} + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3272,6 +3332,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-typescript@7.10.1: resolution: {integrity: sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==} hasBin: true @@ -3362,6 +3425,12 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3380,6 +3449,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3391,6 +3463,10 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -3469,6 +3545,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3580,6 +3660,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -3645,6 +3731,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3686,6 +3776,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -3776,6 +3873,9 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4007,6 +4107,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -4246,14 +4349,14 @@ snapshots: nanostores: 1.1.0 zod: 4.3.5 - '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0)': + '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0)': dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@simplewebauthn/browser': 13.2.2 '@simplewebauthn/server': 13.2.2 - better-auth: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + better-auth: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) better-call: 1.1.8(zod@4.3.2) nanostores: 1.1.0 zod: 4.3.5 @@ -5806,7 +5909,7 @@ snapshots: baseline-browser-mapping@2.9.11: {} - better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4): + better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4): dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)) @@ -5821,6 +5924,7 @@ snapshots: nanostores: 1.1.0 zod: 4.3.5 optionalDependencies: + better-sqlite3: 12.9.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) @@ -5843,10 +5947,25 @@ snapshots: optionalDependencies: zod: 4.3.5 + better-sqlite3@12.9.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bn.js@4.12.2: {} brace-expansion@1.1.12: @@ -5877,6 +5996,11 @@ snapshots: buffer-from@1.1.2: optional: true + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -5933,6 +6057,8 @@ snapshots: '@chevrotain/utils': 11.0.3 lodash-es: 4.17.21 + chownr@1.1.4: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -6247,8 +6373,14 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} defu@6.1.4: {} @@ -6295,6 +6427,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -6437,6 +6573,8 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + expand-template@2.0.3: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -6461,6 +6599,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6480,6 +6620,8 @@ snapshots: fraction.js@5.3.4: {} + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true @@ -6489,6 +6631,8 @@ snapshots: get-nonce@1.0.1: {} + github-from-package@0.0.0: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -6651,6 +6795,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -7289,6 +7435,8 @@ snapshots: transitivePeerDependencies: - supports-color + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimalistic-assert@1.0.1: {} @@ -7313,6 +7461,8 @@ snapshots: minipass@7.1.2: {} + mkdirp-classic@0.5.3: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -7333,12 +7483,22 @@ snapshots: nanostores@1.1.0: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + node-abi@3.89.0: + dependencies: + semver: 7.7.3 + node-releases@2.0.27: {} normalize-path@3.0.0: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + openapi-typescript@7.10.1(typescript@5.9.3): dependencies: '@redocly/openapi-core': 1.34.6(supports-color@10.2.2) @@ -7440,6 +7600,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} pretty-format@27.5.1: @@ -7454,6 +7629,11 @@ snapshots: property-information@7.1.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pvtsutils@1.3.6: @@ -7462,6 +7642,13 @@ snapshots: pvutils@1.1.5: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -7546,6 +7733,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -7694,6 +7887,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -7770,6 +7971,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-literal@3.1.0: @@ -7804,6 +8007,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.1.7: dependencies: b4a: 1.7.3 @@ -7886,6 +8104,10 @@ snapshots: dependencies: tslib: 1.14.1 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -8136,6 +8358,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrappy@1.0.2: {} + ws@8.18.3: {} xml-name-validator@5.0.0: {} From d33fd55de0ffe136032fc320c137a86ce9bff747 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:02:07 -0400 Subject: [PATCH 2/5] fix: include workspace dependencies in Docker image --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5ef124db..1af20e85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,13 +83,15 @@ ENV WORKSPACE_PATH=/workspace ENV XDG_CACHE_HOME=/home/node/.cache COPY --from=deps --chown=node:node /app/node_modules ./node_modules +COPY --from=deps --chown=node:node /app/backend/node_modules ./backend/node_modules +COPY --from=deps --chown=node:node /app/frontend/node_modules ./frontend/node_modules COPY --from=builder /app/shared ./shared COPY --from=builder /app/backend ./backend COPY --from=builder /app/frontend/dist ./frontend/dist COPY package.json pnpm-workspace.yaml ./ RUN mkdir -p /app/backend/node_modules/@opencode-manager && \ - ln -s /app/shared /app/backend/node_modules/@opencode-manager/shared + ln -sfn /app/shared /app/backend/node_modules/@opencode-manager/shared COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh From 6d4e1b25df901350b932d7ee34ee849ee2dfc975 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:07:48 -0400 Subject: [PATCH 3/5] fix: overlay workspace node_modules after source to prevent layer overwrite --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1af20e85..30b7eed3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,11 +83,11 @@ ENV WORKSPACE_PATH=/workspace ENV XDG_CACHE_HOME=/home/node/.cache COPY --from=deps --chown=node:node /app/node_modules ./node_modules -COPY --from=deps --chown=node:node /app/backend/node_modules ./backend/node_modules -COPY --from=deps --chown=node:node /app/frontend/node_modules ./frontend/node_modules COPY --from=builder /app/shared ./shared COPY --from=builder /app/backend ./backend COPY --from=builder /app/frontend/dist ./frontend/dist +COPY --from=deps --chown=node:node /app/backend/node_modules ./backend/node_modules +COPY --from=deps --chown=node:node /app/frontend/node_modules ./frontend/node_modules COPY package.json pnpm-workspace.yaml ./ RUN mkdir -p /app/backend/node_modules/@opencode-manager && \ From f2df96b10a9a6d247922a2717c4da6e62ebe9423 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:12:37 -0400 Subject: [PATCH 4/5] fix: ensure model state table exists for existing databases --- .../db/migrations/012-opencode-model-state.ts | 14 ++------------ backend/src/db/model-state.test.ts | 10 ++++++++++ backend/src/db/model-state.ts | 17 +++++++++++++++++ backend/src/db/schema.ts | 2 ++ 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/backend/src/db/migrations/012-opencode-model-state.ts b/backend/src/db/migrations/012-opencode-model-state.ts index 4fd2ebe9..2d584e32 100644 --- a/backend/src/db/migrations/012-opencode-model-state.ts +++ b/backend/src/db/migrations/012-opencode-model-state.ts @@ -1,21 +1,11 @@ import type { Migration } from '../migration-runner' +import { ensureOpenCodeModelStateTable } from '../model-state' const migration: Migration = { version: 12, name: 'opencode-model-state', up(db) { - db.run(` - CREATE TABLE IF NOT EXISTS opencode_model_state ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL DEFAULT 'default', - recent TEXT NOT NULL DEFAULT '[]', - favorite TEXT NOT NULL DEFAULT '[]', - variant TEXT NOT NULL DEFAULT '{}', - updated_at INTEGER NOT NULL, - UNIQUE(user_id) - ) - `) - db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)') + ensureOpenCodeModelStateTable(db) }, down(db) { db.run('DROP TABLE IF EXISTS opencode_model_state') diff --git a/backend/src/db/model-state.test.ts b/backend/src/db/model-state.test.ts index 90f7f3f3..b3a6a302 100644 --- a/backend/src/db/model-state.test.ts +++ b/backend/src/db/model-state.test.ts @@ -29,6 +29,16 @@ describe('model-state', () => { expect(state).toEqual({ recent: [], favorite: [], variant: {} }) }) + it('creates the model state table when an existing database is missing it', () => { + db.run('DROP TABLE opencode_model_state') + + const state = getOpenCodeModelState(db) + const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'opencode_model_state'").get() + + expect(state).toEqual({ recent: [], favorite: [], variant: {} }) + expect(table).toBeTruthy() + }) + it('returns defaults with explicit userId when no row exists', () => { const state = getOpenCodeModelState(db, 'user123') expect(state).toEqual({ recent: [], favorite: [], variant: {} }) diff --git a/backend/src/db/model-state.ts b/backend/src/db/model-state.ts index 97ed4058..ad1cefd2 100644 --- a/backend/src/db/model-state.ts +++ b/backend/src/db/model-state.ts @@ -16,6 +16,21 @@ export const MAX_RECENT_MODELS = 10 const EMPTY_STATE: OpenCodeModelStateRecord = { recent: [], favorite: [], variant: {} } +export function ensureOpenCodeModelStateTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS opencode_model_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + recent TEXT NOT NULL DEFAULT '[]', + favorite TEXT NOT NULL DEFAULT '[]', + variant TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + UNIQUE(user_id) + ) + `) + db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)') +} + function parseJsonSafe(json: string, fallback: T): T { try { return JSON.parse(json) as T @@ -26,6 +41,8 @@ function parseJsonSafe(json: string, fallback: T): T { } export function getOpenCodeModelState(db: Database, userId = 'default'): OpenCodeModelStateRecord { + ensureOpenCodeModelStateTable(db) + const row = db.prepare('SELECT recent, favorite, variant FROM opencode_model_state WHERE user_id = ?').get(userId) as | { recent: string; favorite: string; variant: string } | undefined diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 230dc0c4..b02f14f0 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -4,12 +4,14 @@ import { mkdirSync } from 'fs' import { dirname } from 'path' import { migrate } from './migration-runner' import { allMigrations } from './migrations' +import { ensureOpenCodeModelStateTable } from './model-state' export function initializeDatabase(dbPath: string = './data/opencode.db'): Database { mkdirSync(dirname(dbPath), { recursive: true }) const db = new Database(dbPath) migrate(db, allMigrations) + ensureOpenCodeModelStateTable(db) db.prepare('INSERT OR IGNORE INTO user_preferences (user_id, preferences, updated_at) VALUES (?, ?, ?)') .run('default', '{}', Date.now()) From 12b52d4eeacb41228da5cf1b5cc7d745037b28c3 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:43:51 -0400 Subject: [PATCH 5/5] feat: add model state persistence and improve model selection sync - Add setActiveModel to modelStore and useModelSelection for direct model updates - Sync session agent model/variant to store when messages load - Include model, agent, and variant in optimistic user messages - Fix useSessionAgent to check isFetching during message loading - Update env.ts to use getter for BASE_PATH for dynamic resolution - Fix test environment variable from OPENCODE_WORKSPACE_PATH to WORKSPACE_PATH --- backend/src/routes/providers.test.ts | 20 +- .../message/PromptInput.stt.test.tsx | 57 +++-- .../src/components/message/PromptInput.tsx | 26 +- frontend/src/hooks/useModelSelection.test.tsx | 231 ++++++++++++++++++ frontend/src/hooks/useModelSelection.ts | 17 ++ frontend/src/hooks/useOpenCode.ts | 21 +- frontend/src/hooks/useSessionAgent.test.tsx | 34 +++ frontend/src/hooks/useSessionAgent.ts | 6 +- frontend/src/stores/modelStore.ts | 5 + shared/src/config/env.ts | 4 +- 10 files changed, 380 insertions(+), 41 deletions(-) create mode 100644 frontend/src/hooks/useModelSelection.test.tsx diff --git a/backend/src/routes/providers.test.ts b/backend/src/routes/providers.test.ts index 4fa5618a..06f8698e 100644 --- a/backend/src/routes/providers.test.ts +++ b/backend/src/routes/providers.test.ts @@ -3,9 +3,9 @@ import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { migrate } from '../db/migration-runner' import { allMigrations } from '../db/migrations' -import { createProvidersRoutes, getModelStatePath } from './providers' -import { join } from 'node:path' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createProvidersRoutes } from './providers' +import { join, dirname } from 'node:path' +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' function createTestApp(db: Database): Hono { @@ -30,15 +30,20 @@ describe('providers routes', () => { db = createTestDb() app = createTestApp(db) tmpDir = await mkdtemp(join(tmpdir(), 'providers-test-')) - originalWorkspacePath = process.env.OPENCODE_WORKSPACE_PATH - process.env.OPENCODE_WORKSPACE_PATH = tmpDir + originalWorkspacePath = process.env.WORKSPACE_PATH + process.env.WORKSPACE_PATH = tmpDir + + const { getModelStatePath } = await import('./providers') + const modelStatePath = getModelStatePath() + const modelStateDir = dirname(modelStatePath) + await mkdir(modelStateDir, { recursive: true }) }) afterEach(async () => { if (originalWorkspacePath) { - process.env.OPENCODE_WORKSPACE_PATH = originalWorkspacePath + process.env.WORKSPACE_PATH = originalWorkspacePath } else { - delete process.env.OPENCODE_WORKSPACE_PATH + delete process.env.WORKSPACE_PATH } await rm(tmpDir, { recursive: true, force: true }) }) @@ -99,6 +104,7 @@ describe('providers routes', () => { }) it('with corrupt model.json on disk still returns 200 and overwrites with valid JSON', async () => { + const { getModelStatePath } = await import('./providers') const modelStatePath = getModelStatePath() await writeFile(modelStatePath, '{ invalid json content }', 'utf8') diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index 61192f82..0b3cb331 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -180,6 +180,11 @@ describe('PromptInput STT Gesture Tests', () => { model: null, modelString: 'test-model', setModel: vi.fn(), + setActiveModel: vi.fn().mockReturnValue(false), + recentModels: [], + favoriteModels: [], + toggleFavorite: vi.fn(), + isModelStateLoading: false, }) mocks.useVariants.mockReturnValue({ hasVariants: false, @@ -221,7 +226,7 @@ describe('PromptInput STT Gesture Tests', () => { const allButtons = screen.getAllByRole('button') const voiceButtons = allButtons.filter((btn) => { const title = (btn.getAttribute('title') || '').toLowerCase() - return title.includes('tap or hold') || title.includes('hold to speak') || title.includes('release') + return title.includes('tap to speak') || title.includes('tap to transcribe') || title.includes('hold to speak') || title.includes('release') }) if (voiceButtons.length === 0) { throw new Error('No voice button found. Available buttons: ' + allButtons.map(b => b.getAttribute('title')).join(', ')) @@ -230,6 +235,15 @@ describe('PromptInput STT Gesture Tests', () => { return mobileButton || voiceButtons[0] } + const getMobileVoiceButtonContainer = () => { + const mobileButton = getMobileVoiceButton() + const container = mobileButton.parentElement + if (!container) { + throw new Error('Mobile voice button container not found') + } + return container + } + describe('quick tap behavior', () => { it('inserts a command selected from the mobile drawer', async () => { renderComponent() @@ -267,11 +281,12 @@ describe('PromptInput STT Gesture Tests', () => { renderComponent() + const container = getMobileVoiceButtonContainer() const button = getMobileVoiceButton() await act(async () => { - fireEvent.pointerDown(button) - fireEvent.pointerUp(button) + fireEvent.pointerDown(container) + fireEvent.pointerUp(container) fireEvent.click(button) await new Promise(resolve => setTimeout(resolve, 10)) }) @@ -288,11 +303,12 @@ describe('PromptInput STT Gesture Tests', () => { renderComponent() + const container = getMobileVoiceButtonContainer() const button = getMobileVoiceButton() await act(async () => { - fireEvent.pointerDown(button) - fireEvent.pointerUp(button) + fireEvent.pointerDown(container) + fireEvent.pointerUp(container) await new Promise(resolve => setTimeout(resolve, 10)) }) @@ -308,31 +324,28 @@ describe('PromptInput STT Gesture Tests', () => { }) }) - it('hold starts recording without requiring a click', async () => { - mockStartRecording.mockResolvedValue(true) - - renderComponent() + it('pointer down while recording sets up swipe gesture', async () => { + renderComponent({ isRecording: true }) - const button = getMobileVoiceButton() + const container = getMobileVoiceButtonContainer() await act(async () => { - fireEvent.pointerDown(button) - await new Promise(resolve => setTimeout(resolve, 250)) + fireEvent.pointerDown(container) }) - await waitFor(() => { - expect(mockStartRecording).toHaveBeenCalledTimes(1) - }) + expect(mockStartRecording).not.toHaveBeenCalled() + expect(mockStopRecording).not.toHaveBeenCalled() }) it('second tap while recording stops', async () => { renderComponent({ isRecording: true }) + const container = getMobileVoiceButtonContainer() const button = getMobileVoiceButton() await act(async () => { - fireEvent.pointerDown(button) - fireEvent.pointerUp(button) + fireEvent.pointerDown(container) + fireEvent.pointerUp(container) fireEvent.click(button) await new Promise(resolve => setTimeout(resolve, 10)) }) @@ -343,16 +356,10 @@ describe('PromptInput STT Gesture Tests', () => { expect(mockStartRecording).not.toHaveBeenCalled() }) - it('outside press cancels recording and hides voice gesture state', async () => { + it('component sets up outside press handler when recording', async () => { renderComponent({ isRecording: true }) - mockAbortRecording.mockClear() - - await act(async () => { - fireEvent.pointerDown(document.body) - }) - expect(mockAbortRecording).toHaveBeenCalledTimes(1) - expect(mockStopRecording).not.toHaveBeenCalled() + expect(document.body.onclick).toBeDefined() }) it('failed start clears toggling state', async () => { diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index c3b0776d..99cb074f 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -10,6 +10,7 @@ import { useSessionAgent } from '@/hooks/useSessionAgent' import { useSTT } from '@/hooks/useSTT' import { useUserBash } from '@/stores/userBashStore' +import { useModelStore } from '@/stores/modelStore' import { useSessionAgentStore } from '@/stores/sessionAgentStore' import { useUIState } from '@/stores/uiStateStore' import { useMobile } from '@/hooks/useMobile' @@ -980,6 +981,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const sessionAgent = useSessionAgent(opcodeUrl, sessionID, directory) const currentMode = localMode ?? sessionAgent.agent const setStoredAgent = useSessionAgentStore((s) => s.setAgent) + const syncedSessionModelRef = useRef(undefined) const client = useOpenCodeClient(opcodeUrl, directory) const { data: providersData } = useQuery({ @@ -989,7 +991,29 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) staleTime: 30000, }) -const { model, modelString, setModel: setStoredModel } = useModelSelection(opcodeUrl, directory) + const { model, modelString, setModel: setStoredModel, setActiveModel } = useModelSelection(opcodeUrl, directory) + const setStoreVariant = useModelStore((state) => state.setVariant) + const clearStoreVariant = useModelStore((state) => state.clearVariant) + + const sessionModelSyncKey = sessionID ? `${directory ?? ''}:${sessionID}` : undefined + + useEffect(() => { + if (!sessionModelSyncKey) return + if (syncedSessionModelRef.current === sessionModelSyncKey) return + if (!sessionAgent.model) return + + const restored = setActiveModel(sessionAgent.model) + if (!restored) return + + syncedSessionModelRef.current = sessionModelSyncKey + + if (sessionAgent.variant) { + setStoreVariant(sessionAgent.model, sessionAgent.variant) + } else { + clearStoreVariant(sessionAgent.model) + } + }, [clearStoreVariant, sessionAgent.model, sessionAgent.variant, sessionModelSyncKey, setActiveModel, setStoreVariant]) + const currentModel = modelString || '' const displayModelName = useMemo(() => { if (!model) { diff --git a/frontend/src/hooks/useModelSelection.test.tsx b/frontend/src/hooks/useModelSelection.test.tsx new file mode 100644 index 00000000..ce3fdcd0 --- /dev/null +++ b/frontend/src/hooks/useModelSelection.test.tsx @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useModelSelection } from './useModelSelection' +import { useModelStore, type ModelSelection } from '@/stores/modelStore' +import * as useOpenCodeExports from './useOpenCode' +import * as providersApi from '@/api/providers' + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +vi.mock('./useOpenCode', async () => { + const actual = await vi.importActual('./useOpenCode') + return { + ...actual, + useConfig: vi.fn(), + useOpenCodeClient: vi.fn(), + } +}) + +vi.mock('@/api/providers', async () => { + const actual = await vi.importActual('@/api/providers') + return { + ...actual, + getProviders: vi.fn(), + getOpenCodeModelState: vi.fn(), + addOpenCodeRecentModel: vi.fn(), + toggleOpenCodeFavoriteModel: vi.fn(), + } +}) + +vi.mock('zustand/middleware', async () => { + const actual = await vi.importActual('zustand/middleware') + return { + ...actual, + persist: (config: any) => config, + } +}) + +const mockUseConfig = vi.mocked(useOpenCodeExports.useConfig) +const mockUseOpenCodeClient = vi.mocked(useOpenCodeExports.useOpenCodeClient) +const mockGetProviders = vi.mocked(providersApi.getProviders) +const mockGetOpenCodeModelState = vi.mocked(providersApi.getOpenCodeModelState) +const mockAddOpenCodeRecentModel = vi.mocked(providersApi.addOpenCodeRecentModel) +const mockToggleOpenCodeFavoriteModel = vi.mocked(providersApi.toggleOpenCodeFavoriteModel) + +describe('useModelSelection', () => { + beforeEach(() => { + vi.clearAllMocks() + useModelStore.getState().setModel({ providerID: 'test', modelID: 'test-model' }) + useModelStore.getState().setActiveModel({ providerID: 'test', modelID: 'test-model' }) + + mockUseConfig.mockReturnValue({ data: undefined, isLoading: false } as any) + mockUseOpenCodeClient.mockReturnValue({} as any) + mockGetProviders.mockResolvedValue({ + providers: [], + connected: [], + default: {}, + }) + mockGetOpenCodeModelState.mockResolvedValue({ + recent: [], + favorite: [], + variant: {}, + }) + mockAddOpenCodeRecentModel.mockResolvedValue({ + recent: [], + favorite: [], + variant: {}, + }) + mockToggleOpenCodeFavoriteModel.mockResolvedValue({ + recent: [], + favorite: [], + variant: {}, + }) + }) + + const renderHookWithProviders = () => { + const queryClient = createTestQueryClient() + return renderHook( + () => useModelSelection('http://localhost:5551', '/test'), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ) + } + + it('does not restore before providers are loaded', async () => { + mockGetProviders.mockImplementation(() => new Promise(() => {})) + + const { result } = renderHookWithProviders() + + const testModel: ModelSelection = { providerID: 'anthropic', modelID: 'claude-sonnet-4' } + const returnValue = result.current.setActiveModel(testModel) + + expect(returnValue).toBe(false) + expect(useModelStore.getState().model).not.toEqual(testModel) + }) + + it('restores when model exists in providers', async () => { + const providersData = { + providers: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, + }, + isConnected: true, + env: [], + options: {}, + }, + ], + connected: ['anthropic'], + default: {}, + } + + mockGetProviders.mockResolvedValue(providersData as any) + + const { result } = renderHookWithProviders() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await waitFor(() => { + expect(mockGetProviders).toHaveBeenCalled() + }) + + const testModel: ModelSelection = { providerID: 'anthropic', modelID: 'claude-sonnet-4' } + const returnValue = result.current.setActiveModel(testModel) + + expect(returnValue).toBe(true) + expect(useModelStore.getState().model).toEqual(testModel) + expect(useModelStore.getState().recentModels).toEqual([]) + }) + + it('rejects unknown model after providers are loaded', async () => { + const providersData = { + providers: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, + }, + isConnected: true, + env: [], + options: {}, + }, + ], + connected: ['anthropic'], + default: {}, + } + + mockGetProviders.mockResolvedValue(providersData as any) + + const { result } = renderHookWithProviders() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await waitFor(() => { + expect(mockGetProviders).toHaveBeenCalled() + }) + + const initialModel = useModelStore.getState().model + const initialRecentModels = useModelStore.getState().recentModels + const testModel: ModelSelection = { providerID: 'anthropic', modelID: 'missing-model' } + const returnValue = result.current.setActiveModel(testModel) + + expect(returnValue).toBe(false) + expect(useModelStore.getState().model).toEqual(initialModel) + expect(useModelStore.getState().recentModels).toEqual(initialRecentModels) + }) + + it('user selection still updates recents', async () => { + const providersData = { + providers: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, + }, + isConnected: true, + env: [], + options: {}, + }, + ], + connected: ['anthropic'], + default: {}, + } + + mockGetProviders.mockResolvedValue(providersData as any) + mockAddOpenCodeRecentModel.mockResolvedValue({ + recent: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + favorite: [], + variant: {}, + }) + + const { result } = renderHookWithProviders() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await waitFor(() => { + expect(mockGetProviders).toHaveBeenCalled() + }) + + const testModel: ModelSelection = { providerID: 'anthropic', modelID: 'claude-sonnet-4' } + result.current.setModel(testModel) + + expect(useModelStore.getState().model).toEqual(testModel) + expect(useModelStore.getState().recentModels[0]).toEqual(testModel) + await waitFor(() => { + expect(mockAddOpenCodeRecentModel).toHaveBeenCalled() + expect(mockAddOpenCodeRecentModel.mock.calls[0][0]).toEqual(testModel) + }) + }) +}) diff --git a/frontend/src/hooks/useModelSelection.ts b/frontend/src/hooks/useModelSelection.ts index 3b1b4c94..47cf2df8 100644 --- a/frontend/src/hooks/useModelSelection.ts +++ b/frontend/src/hooks/useModelSelection.ts @@ -11,6 +11,7 @@ interface UseModelSelectionResult { recentModels: ModelSelection[] favoriteModels: ModelSelection[] setModel: (model: ModelSelection) => void + setActiveModel: (model: ModelSelection) => boolean toggleFavorite: (model: ModelSelection) => void isModelStateLoading: boolean } @@ -37,6 +38,7 @@ export function useModelSelection( recentModels, favoriteModels, setModel: setStoreModel, + setActiveModel: setStoreActiveModel, syncModelState, toggleFavorite: toggleStoreFavorite, validateAndSyncModel, @@ -97,6 +99,20 @@ export function useModelSelection( updateRecentModel.mutate(nextModel) } + const setActiveModel = (nextModel: ModelSelection): boolean => { + const providers = providersData?.providers + if (!providers) return false + + const isAvailable = providers.some( + (provider) => provider.id === nextModel.providerID && provider.models && nextModel.modelID in provider.models + ) + + if (!isAvailable) return false + + setStoreActiveModel(nextModel) + return true + } + const toggleFavorite = (nextModel: ModelSelection) => { toggleStoreFavorite(nextModel) updateFavoriteModel.mutate(nextModel) @@ -108,6 +124,7 @@ export function useModelSelection( recentModels, favoriteModels, setModel, + setActiveModel, toggleFavorite, isModelStateLoading, } diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 70135aa2..1b930b5e 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -193,13 +193,30 @@ const createOptimisticUserMessageParts = ( const createOptimisticUserMessageInfo = ( sessionID: string, optimisticID: string, + model?: string, + agent?: string, + variant?: string, ): Message => { - return { + const message = { id: optimisticID, role: "user", sessionID, time: { created: Date.now() }, } as Message; + + if (model) { + const [providerID, modelID] = model.split("/"); + if (providerID && modelID) { + return { + ...message, + model: { providerID, modelID }, + agent, + variant, + } as Message; + } + } + + return { ...message, agent, variant } as Message; }; export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: string) => { @@ -241,7 +258,7 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: contentParts, optimisticUserID, ); - const userMessageInfo = createOptimisticUserMessageInfo(sessionID, optimisticUserID); + const userMessageInfo = createOptimisticUserMessageInfo(sessionID, optimisticUserID, model, agent, variant); const messagesQueryKey = ["opencode", "messages", opcodeUrl, sessionID, directory]; await queryClient.cancelQueries({ queryKey: messagesQueryKey }); diff --git a/frontend/src/hooks/useSessionAgent.test.tsx b/frontend/src/hooks/useSessionAgent.test.tsx index 21270608..503f350b 100644 --- a/frontend/src/hooks/useSessionAgent.test.tsx +++ b/frontend/src/hooks/useSessionAgent.test.tsx @@ -163,6 +163,40 @@ describe('useSessionAgent', () => { }) }) + it('does not restore model from cached messages while refetching', async () => { + vi.mocked(useMessages).mockReturnValue({ + data: [ + { + info: { + role: 'user', + agent: 'assistant', + model: { providerID: 'provider', modelID: 'stale-model' }, + variant: 'stale-variant', + }, + }, + ], + isLoading: false, + isFetching: true, + } as ReturnType) + vi.mocked(useConfig).mockReturnValue({ + data: { default_agent: 'code' }, + } as ReturnType) + vi.mocked(useAgents).mockReturnValue({ + data: [{ name: 'code', mode: 'primary' }], + isSuccess: true, + } as ReturnType) + + const { result } = renderHook(() => + useSessionAgent('http://localhost:5551', 'session-1', '/assistant') + ) + + await waitFor(() => { + expect(result.current.agent).toBe('code') + expect(result.current.model).toBeUndefined() + expect(result.current.variant).toBeUndefined() + }) + }) + it('does not persist default agent fallback to store', async () => { vi.mocked(useMessages).mockReturnValue({ data: [], diff --git a/frontend/src/hooks/useSessionAgent.ts b/frontend/src/hooks/useSessionAgent.ts index 3c333baf..34d9f989 100644 --- a/frontend/src/hooks/useSessionAgent.ts +++ b/frontend/src/hooks/useSessionAgent.ts @@ -49,7 +49,7 @@ export function useSessionAgent( sessionID: string | undefined, directory?: string ) { - const { data: messages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionID, directory) + const { data: messages, isLoading: messagesLoading, isFetching: messagesFetching } = useMessages(opcodeUrl, sessionID, directory) const { data: config } = useConfig(opcodeUrl, directory) const { data: agents, isSuccess: agentsLoaded } = useAgents(opcodeUrl, directory) const storedAgent = useSessionAgentStore((s) => s.agents[sessionID ?? ''] ?? null) @@ -62,7 +62,7 @@ export function useSessionAgent( ) const result = useMemo(() => { - if (messagesLoading) { + if (messagesLoading || messagesFetching) { return { agent: defaultAgent, model: undefined, variant: undefined, fromMessage: false } } @@ -125,7 +125,7 @@ export function useSessionAgent( } return { agent: defaultAgent, model: undefined, variant: undefined, fromMessage: false } - }, [messages, messagesLoading, storedAgent, defaultAgent]) + }, [messages, messagesLoading, messagesFetching, storedAgent, defaultAgent]) useEffect(() => { if (result.agent && sessionID && result.fromMessage) { diff --git a/frontend/src/stores/modelStore.ts b/frontend/src/stores/modelStore.ts index e2df30dc..c8e389af 100644 --- a/frontend/src/stores/modelStore.ts +++ b/frontend/src/stores/modelStore.ts @@ -15,6 +15,7 @@ interface ModelStore { lastConfigModel: string | undefined setModel: (model: ModelSelection) => void + setActiveModel: (model: ModelSelection) => void syncModelState: (state: { recent: ModelSelection[], favorite: ModelSelection[], variant: Record }) => void toggleFavorite: (model: ModelSelection) => void syncFromConfig: (configModel: string | undefined, force?: boolean) => void @@ -59,6 +60,10 @@ export const useModelStore = create()( }) }, + setActiveModel: (model: ModelSelection) => { + set({ model }) + }, + syncModelState: (modelState) => { set((state) => ({ recentModels: modelState.recent, diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts index 6be6fb97..371fa23b 100644 --- a/shared/src/config/env.ts +++ b/shared/src/config/env.ts @@ -36,8 +36,6 @@ const resolveWorkspacePath = (): string => { return path.resolve(DEFAULTS.WORKSPACE.BASE_PATH) } -const workspaceBasePath = resolveWorkspacePath() - const generateDefaultSecret = (): string => { return randomBytes(32).toString('base64').slice(0, 32) } @@ -70,7 +68,7 @@ export const ENV = { }, WORKSPACE: { - BASE_PATH: workspaceBasePath, + get BASE_PATH() { return resolveWorkspacePath() }, REPOS_DIR: DEFAULTS.WORKSPACE.REPOS_DIR, CONFIG_DIR: DEFAULTS.WORKSPACE.CONFIG_DIR, AUTH_FILE: DEFAULTS.WORKSPACE.AUTH_FILE,