diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index fa181ff06..7dd427d79 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -11,6 +11,8 @@ import type { import { UserPreferencesSchema, DEFAULT_USER_PREFERENCES, + DEFAULT_ACTIVITY_WINDOW_MINUTES, + isActivityWindowMinutes, } from '../types/settings' import { isColorThemeId, MANAGER_COLOR_THEME_ID } from '@opencode-manager/shared/themes' @@ -69,6 +71,10 @@ export class SettingsService { parsed.colorTheme = MANAGER_COLOR_THEME_ID } + if (!isActivityWindowMinutes(parsed.activityWindowMinutes)) { + parsed.activityWindowMinutes = DEFAULT_ACTIVITY_WINDOW_MINUTES + } + const validated = UserPreferencesSchema.parse({ ...DEFAULT_USER_PREFERENCES, ...parsed, diff --git a/backend/test/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts index 75f5bf90e..6216c01c6 100644 --- a/backend/test/routes/internal-settings.test.ts +++ b/backend/test/routes/internal-settings.test.ts @@ -181,6 +181,39 @@ describe('internal/settings routes', () => { expect(body.error).toBe('Invalid request body') }) + it('PATCH /api/internal/settings with { activityWindowMinutes: 45 } returns 400 (non-option reject)', async () => { + const res = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ activityWindowMinutes: 45 }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Invalid request body') + }) + + it('PATCH /api/internal/settings with { activityWindowMinutes: 60 } persists an allowed option', async () => { + const patchRes = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ activityWindowMinutes: 60 }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(patchRes.status).toBe(200) + + const getRes = await app.request('/api/internal/settings', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(getRes.status).toBe(200) + const body = await getRes.json() as { preferences: { activityWindowMinutes: number } } + expect(body.preferences.activityWindowMinutes).toBe(60) + }) + it('PATCH /api/internal/settings with { tts: { voice: "x", speed: 1.5 } } merges and preserves apiKey/endpoint', async () => { // Seed a full TTS config (including secrets) directly via settingsService settingsService.updateSettings({ diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 7ec0ef0eb..bc9d2f3ee 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -1519,6 +1519,41 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) }) + describe('PATCH / - activity window validation', () => { + it.each([5, 45, 2000, 30.5])('rejects activityWindowMinutes %s with 400', async (minutes) => { + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { activityWindowMinutes: minutes } }), + })) + const json = await res.json() as { error: string } + + expect(res.status).toBe(400) + expect(json.error).toBe('Invalid settings data') + expect(mockUpdateSettings).not.toHaveBeenCalled() + }) + + it('accepts an allowed activityWindowMinutes value', async () => { + mockGetSettings.mockReturnValue({ + preferences: { activityWindowMinutes: 30 }, + updatedAt: 1, + }) + mockUpdateSettings.mockReturnValue({ + preferences: { activityWindowMinutes: 60 }, + updatedAt: 2, + }) + + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { activityWindowMinutes: 60 } }), + })) + + expect(res.status).toBe(200) + expect(mockUpdateSettings).toHaveBeenCalledWith({ activityWindowMinutes: 60 }, 'default') + }) + }) + describe('PATCH / - sandbox enable guard', () => { afterEach(() => { forceProcessAttestation(null) diff --git a/backend/test/services/settings.test.ts b/backend/test/services/settings.test.ts new file mode 100644 index 000000000..ca25e2daf --- /dev/null +++ b/backend/test/services/settings.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@opencode-manager/shared/schemas' +import { SettingsService } from '../../src/services/settings' +import { allMigrations } from '../../src/db/migrations' +import { migrate } from '../../src/db/migration-runner' + +describe('SettingsService', () => { + let db: Database + let settingsService: SettingsService + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + settingsService = new SettingsService(db) + }) + + it('recovers from a stored activityWindowMinutes outside the allowed options without losing other preferences', () => { + db.prepare( + `INSERT INTO user_preferences (user_id, preferences, updated_at) + VALUES (?, ?, ?)` + ).run('default', JSON.stringify({ activityWindowMinutes: 5, autoScroll: false }), Date.now()) + + const settings = settingsService.getSettings() + + expect(settings.preferences.activityWindowMinutes).toBe(DEFAULT_ACTIVITY_WINDOW_MINUTES) + expect(settings.preferences.autoScroll).toBe(false) + }) +}) diff --git a/backend/test/shared/activity-window-minutes-schema.test.ts b/backend/test/shared/activity-window-minutes-schema.test.ts new file mode 100644 index 000000000..400685a65 --- /dev/null +++ b/backend/test/shared/activity-window-minutes-schema.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + ACTIVITY_WINDOW_MINUTE_OPTIONS, + ActivityWindowMinutesSchema, + UserPreferencesSchema, +} from '@opencode-manager/shared/schemas' + +describe('ActivityWindowMinutesSchema', () => { + it.each(ACTIVITY_WINDOW_MINUTE_OPTIONS)('accepts the configured option %s', (minutes) => { + expect(ActivityWindowMinutesSchema.safeParse(minutes).success).toBe(true) + }) + + it.each([5, 45, 2000, 30.5])('rejects %s', (minutes) => { + expect(ActivityWindowMinutesSchema.safeParse(minutes).success).toBe(false) + }) + + it('rejects a non-option value through UserPreferencesSchema', () => { + expect(UserPreferencesSchema.shape.activityWindowMinutes.safeParse(45).success).toBe(false) + expect(UserPreferencesSchema.shape.activityWindowMinutes.safeParse(60).success).toBe(true) + }) +}) diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index 0d9d01edb..03ec96c13 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -125,6 +125,19 @@ describe('OpenCode facade', () => { expect(lastRequest().init.method).toBe('GET') }) + it('omits the parentID filter when including child sessions', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ data: [sessionInfo('ses_child', '/repo')], cursor: {} }), + ) + + await listSessionPage({ directory: '/repo', limit: 25, order: 'desc', includeChildren: true }) + + expect(lastRequest().url).toBe( + 'http://localhost/api/opencode/api/session?limit=25&order=desc&directory=%2Frepo', + ) + expect(lastRequest().init.method).toBe('GET') + }) + it('reads a single session from the V2 session route', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: sessionInfo('ses_1', '/repo') })) diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 0630b25d1..fd5220b58 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -48,6 +48,7 @@ export interface SessionPageInput { order?: 'asc' | 'desc' search?: string cursor?: string + includeChildren?: boolean } export interface CreateSessionInput { @@ -102,7 +103,7 @@ export async function listSessionPage(input: SessionPageInput): Promise