diff --git a/src/main/index.ts b/src/main/index.ts index 485df878..f73fe647 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,8 +11,9 @@ import { registerHandlers as registerSearchHandlers } from './ipc/search' import { registerHandlers as registerShellHandlers } from './ipc/shell' import { registerAgentHookForwarding } from './ipc/agentHookEvents' import { registerHandlers as registerGitMonitorHandlers } from './ipc/git-monitor' -import { registerHandlers as registerStoreHandlers, loadSettingsSyncFromDisk, getSettingSync, setSettingsFromMain } from './store' -import { registerUIStateHandlers } from './uiStateStore' +import { registerHandlers as registerStoreHandlers, loadSettingsSyncFromDisk, getSettingSync } from './store' +import { getSettingsFilePath } from './settingsFile' +import { getUIStateSync, loadUIStateSync, migrateLegacyLifecycleState, registerUIStateHandlers, setUIStateFromMain } from './uiStateStore' import { registerProjectStateHandlers } from './projectWorkspaceStore' import { registerHandlers as registerMenuHandlers } from './ipc/menu' import { registerHandlers as registerNotificationHandlers } from './ipc/notifications' @@ -20,13 +21,12 @@ import { registerSkillHandlers } from '../skills/main/ipcSkills' import { registerWorkspaceHandlers } from './workspaceManager' import { buildApplicationMenu, setNewMainWindowFn } from './menu' import { initShellEnv, getShellEnv } from './shellEnv' -import { currentExclusionSet } from './ipc/filesystem' import { initAutoUpdater } from './auto-updater' import { initSentry, captureMainException, flushSentry } from './sentry' import { initAnalytics, devSimulateUpdateFrom, hasRunBefore } from './analytics' import { startPerfMonitor, getLatestSnapshot } from './perf/perfMonitor' import { PERF_GET } from '../shared/ipc-channels' -import { TELEMETRY_NOTICE_VERSION } from '../shared/types' +import { FILE_EXCLUSIONS, TELEMETRY_NOTICE_VERSION } from '../shared/types' import { installWebContentsSecurity } from './webSecurity' import { installProxyAuthHandler } from './browserProxy' import { installBundledSkill } from './installBundledSkill' @@ -223,6 +223,8 @@ log.info('Cate v%s starting (electron %s, node %s, platform %s)', app.getVersion // Load persisted settings synchronously so window-creation code paths can read // them before the async electron-store finishes initializing. loadSettingsSyncFromDisk() +loadUIStateSync() +migrateLegacyLifecycleState(getSettingsFilePath()) // Optional GPU-rasterization workaround (off by default). Under this app's GPU // load — many live xterm WebGL contexts + the worktree-territory WebGL2 renderer @@ -244,8 +246,8 @@ if (getSettingSync('disableGpuRasterization')) { // user whose acknowledged notice version is below TELEMETRY_NOTICE_VERSION // sees it once, updaters included. if (hasRunBefore()) { - if (!getSettingSync('onboardingCompleted')) { - void setSettingsFromMain({ onboardingCompleted: true }) + if (!getUIStateSync('onboardingCompleted')) { + setUIStateFromMain('onboardingCompleted', true) } } @@ -254,7 +256,8 @@ if (hasRunBefore()) { // drive. Mark both as already handled so e2e starts on a clean canvas. Runs // before the renderer queries settings, so the dialogs never flash. if (IS_E2E) { - void setSettingsFromMain({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION, onboardingCompleted: true }) + setUIStateFromMain('telemetryNoticeAcknowledgedVersion', TELEMETRY_NOTICE_VERSION) + setUIStateFromMain('onboardingCompleted', true) } // Initialize Sentry as early as possible — before any IPC handlers or windows. @@ -327,7 +330,7 @@ app.whenReady().then(async () => { : undefined runtimes.ensureLocalRuntime({ root: app.getPath('home'), - exclusions: [...currentExclusionSet()], + exclusions: FILE_EXCLUSIONS, env: e2ePathPrefix ? { ...runtimeEnv, PATH: `${e2ePathPrefix}${path.delimiter}${runtimeEnv.PATH ?? ''}` } : runtimeEnv, diff --git a/src/main/ipc/fileExclusions.test.ts b/src/main/ipc/fileExclusions.test.ts index 8a7fb01b..37ed4fcd 100644 --- a/src/main/ipc/fileExclusions.test.ts +++ b/src/main/ipc/fileExclusions.test.ts @@ -3,6 +3,7 @@ import os from 'node:os' import path from 'node:path' import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest' import type { FileSearchResult, FileTreeNode } from '../../shared/types' +import { FILE_EXCLUSIONS } from '../../shared/types' // Capture the handlers registered via ipcMain.handle so we can invoke them // directly without a live Electron main process. @@ -21,31 +22,21 @@ vi.mock('../windowRegistry', () => ({ sendToWindow: vi.fn(), })) -// Controllable exclusion list. filesystem.ts reads this live on every call via -// getSettingSync('fileExclusions') — mutating it between calls models a user -// editing the setting at runtime (the PR's "no relaunch" guarantee). -let exclusions: string[] = [] -vi.mock('../store', () => ({ - getSettingSync: (key: string) => (key === 'fileExclusions' ? exclusions : undefined), -})) - const { registerHandlers } = await import('./filesystem') const { addAllowedRoot, removeAllowedRoot } = await import('./pathValidation') const { FS_READ_DIR, FS_SEARCH } = await import('../../shared/ipc-channels') const { registerTestDaemonRuntime } = await import('../runtime/testHarness') registerHandlers() -const testRuntime = registerTestDaemonRuntime() +registerTestDaemonRuntime(FILE_EXCLUSIONS) const readDirHandler = handlers.get(FS_READ_DIR)! const searchHandler = handlers.get(FS_SEARCH)! const fakeEvent = { sender: {} } as unknown const readDir = async (p: string): Promise => { - await testRuntime.setExclusions(exclusions) return readDirHandler(fakeEvent, p, 'local') as Promise } const search = async (root: string, q: string): Promise => { - await testRuntime.setExclusions(exclusions) return searchHandler(fakeEvent, root, q, undefined, 'local') as Promise } const names = (nodes: FileTreeNode[]) => nodes.map((n) => n.name).sort() @@ -55,7 +46,6 @@ describe('file exclusions across explorer + search', () => { let root: string beforeEach(async () => { - exclusions = [] // realpath so the registered allowed root matches validatePathStrict's // symlink-resolved comparison (e.g. /tmp → /private/tmp on macOS). root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'cate-excl-'))) @@ -79,18 +69,11 @@ describe('file exclusions across explorer + search', () => { await fs.rm(root, { recursive: true, force: true }) }) - test('empty exclusion list shows everything', async () => { - exclusions = [] - expect(names(await readDir(root))).toEqual(['keep.txt', 'node_modules', 'src']) - }) - - test('readDir hides an excluded folder by exact name', async () => { - exclusions = ['node_modules'] + test('readDir hides an internally excluded folder by exact name', async () => { expect(names(await readDir(root))).toEqual(['keep.txt', 'src']) }) test('readDir hides a same-named file at a nested level (exact-name, any depth)', async () => { - exclusions = ['node_modules'] // The folder-vs-file distinction does not matter: a file named like an // exclusion is dropped too, matching how the watcher now ignores both // `**/` and `**//**`. @@ -98,22 +81,10 @@ describe('file exclusions across explorer + search', () => { }) test('search skips excluded folders and same-named files', async () => { - exclusions = ['node_modules'] // Name-only search: 'txt' matches keep.txt and src/app.txt by name. const found = relPaths(await search(root, 'txt')) expect(found).toEqual(['keep.txt', 'src/app.txt']) // Nothing under node_modules/, and not the src/node_modules file either. expect(found.some((p) => p.includes('node_modules'))).toBe(false) }) - - test('exclusions are read live: editing the list takes effect on the next call', async () => { - exclusions = [] - expect(names(await readDir(root))).toContain('node_modules') - expect(relPaths(await search(root, 'txt'))).toContain('node_modules/pkg.txt') - - // User edits the setting at runtime — no relaunch. - exclusions = ['node_modules'] - expect(names(await readDir(root))).not.toContain('node_modules') - expect(relPaths(await search(root, 'txt')).some((p) => p.includes('node_modules'))).toBe(false) - }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 72fd3839..73586374 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -30,15 +30,9 @@ import { FS_SEARCH, FS_READ_BINARY, } from '../../shared/ipc-channels' -import { FileTreeNode, FileSearchResult, FileSearchOptions } from '../../shared/types' +import { FILE_EXCLUSIONS, FileTreeNode, FileSearchResult, FileSearchOptions } from '../../shared/types' import { broadcastToAll, sendToWindow, windowFromEvent } from '../windowRegistry' -import { getSettingSync } from '../store' - -// Read the user-configured exclusion list live so changes take effect without -// a relaunch. Built into a Set per call for fast membership checks. -export function currentExclusionSet(): Set { - return new Set(getSettingSync('fileExclusions')) -} +const exclusionSet = new Set(FILE_EXCLUSIONS) /** Trailing-edge debounce window for coalescing watcher bursts. */ const DISPATCH_DEBOUNCE_MS = 16 @@ -65,8 +59,8 @@ function watcherKey(windowId: number, dirPath: string, scopeId?: string): string // Leaf filesystem operations live in the electron-free capability module // (src/runtime/capabilities/file.ts) so the local process and the standalone // runtime daemon share ONE implementation. Path-only ops are re-exported -// verbatim; the two ops that need the live `fileExclusions` setting (readDir, -// searchFiles) and import-entry logging are wrapped below to inject it. +// verbatim; readDir/searchFiles are wrapped below to inject Cate's fixed +// internal exclusions, and import-entry logging is handled locally. // ----------------------------------------------------------------------------- export { @@ -86,7 +80,7 @@ import { searchFiles as capSearchFiles, } from '../../runtime/capabilities/file' export function readDir(dirPath: string): Promise { - return capReadDir(dirPath, currentExclusionSet()) + return capReadDir(dirPath, exclusionSet) } export function searchFiles( @@ -94,7 +88,7 @@ export function searchFiles( query: string, opts: FileSearchOptions = {}, ): Promise { - return capSearchFiles(rootPath, query, currentExclusionSet(), opts) + return capSearchFiles(rootPath, query, exclusionSet, opts) } // --------------------------------------------------------------------------- diff --git a/src/main/ipc/git.ts b/src/main/ipc/git.ts index 7e8d8040..68db4509 100644 --- a/src/main/ipc/git.ts +++ b/src/main/ipc/git.ts @@ -201,7 +201,7 @@ export function registerHandlers(): void { repoCwd: string, prNumber: number, targetPath: string, - options: { symlinkPaths?: string[] } | undefined, + options: undefined, workspaceId?: string, ) => { const { vcs, path, runtimeId } = vcsFor(repoCwd) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index dabe47db..9ee85eb4 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -38,6 +38,7 @@ import type { RuntimeStatusEvent, SshHostEntry, } from '../../shared/types' +import { FILE_EXCLUSIONS } from '../../shared/types' import { broadcastToAll } from '../windowRegistry' import { assertAbsoluteRuntimePath, formatLocator } from '../../shared/runtimeLocator' import { @@ -183,10 +184,7 @@ export async function buildTransport(runtimeId: string, spec: RemoteConnectSpec) distro: spec.distro, root: spec.distroPath, id: runtimeId, - // Same launch config the local daemon gets (main/index.ts) so a WSL host - // honors the exclusion + idle-suspend settings identically. Later live - // changes are forwarded to every connected runtime by the store. - exclusions: getSetting('fileExclusions'), + exclusions: FILE_EXCLUSIONS, idleSuspend: getSetting('autoSuspendIdleTerminals'), }) } @@ -224,10 +222,7 @@ export async function buildTransport(runtimeId: string, spec: RemoteConnectSpec) // the user's PATH. Cate resolves the login-shell environment at startup; // OpenSSH must receive that same authoritative environment. env: getShellEnv(), - // Same launch config the local daemon gets (main/index.ts) so an SSH host - // honors the exclusion + idle-suspend settings identically. Later live - // changes are forwarded to every connected runtime by the store. - exclusions: getSetting('fileExclusions'), + exclusions: FILE_EXCLUSIONS, idleSuspend: getSetting('autoSuspendIdleTerminals'), }) } diff --git a/src/main/lifecycle/telemetry.ts b/src/main/lifecycle/telemetry.ts index cb3fff5c..a3fd5657 100644 --- a/src/main/lifecycle/telemetry.ts +++ b/src/main/lifecycle/telemetry.ts @@ -1,6 +1,6 @@ import { BrowserWindow, ipcMain } from 'electron' import log from '../logger' -import { setSettingsFromMain } from '../store' +import { setUIStateFromMain } from '../uiStateStore' import { trackAppStart, checkAndReportUpdate } from '../analytics' import { TELEMETRY_ACKNOWLEDGE_NOTICE } from '../../shared/ipc-channels' import { TELEMETRY_NOTICE_VERSION } from '../../shared/types' @@ -18,6 +18,6 @@ export function fireStartupTelemetry(mainWin: BrowserWindow): void { // is bumped. Purely informational; telemetry does not depend on it. export function registerTelemetryNoticeHandler(): void { ipcMain.handle(TELEMETRY_ACKNOWLEDGE_NOTICE, async () => { - await setSettingsFromMain({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION }) + setUIStateFromMain('telemetryNoticeAcknowledgedVersion', TELEMETRY_NOTICE_VERSION) }) } diff --git a/src/main/runtime/types.ts b/src/main/runtime/types.ts index 96bceed9..01201e76 100644 --- a/src/main/runtime/types.ts +++ b/src/main/runtime/types.ts @@ -439,14 +439,14 @@ export interface VcsHost { repoCwd: string, branch: string, targetPath: string, - options?: { createBranch?: boolean; baseRef?: string; symlinkPaths?: string[] }, + options?: { createBranch?: boolean; baseRef?: string }, access?: FileAccessContext, ): Promise<{ path: string; branch: string }> worktreeAddFromPr( repoCwd: string, prNumber: number, targetPath: string, - options?: { symlinkPaths?: string[] }, + options?: undefined, access?: FileAccessContext, ): Promise<{ path: string; branch: string }> worktreeRemove(repoCwd: string, worktreePath: string, options?: { force?: boolean }, access?: FileAccessContext): Promise @@ -498,10 +498,7 @@ export interface Runtime { * the runtime uses its own configured root scope. */ addAllowedRoot(root: string, scopeId?: string): Promise removeAllowedRoot(root: string, scopeId?: string): Promise - /** Replace this runtime's readDir/search exclusion basenames live (the - * daemon's mirror of the fileExclusions setting). For the LOCAL daemon the - * main process forwards this when the setting changes, so the file tree / - * file-name search hide the new set without an app restart. */ + /** Replace this runtime's internal readDir/search exclusion basenames. */ setExclusions(names: string[]): Promise /** Toggle POSIX idle-suspend of backgrounded terminals live (the daemon's * mirror of autoSuspendIdleTerminals). Forwarded to the LOCAL daemon when the diff --git a/src/main/settingsFile.test.ts b/src/main/settingsFile.test.ts index 15385451..39b60dca 100644 --- a/src/main/settingsFile.test.ts +++ b/src/main/settingsFile.test.ts @@ -55,10 +55,10 @@ describe('settingsFile', () => { m.loadSettingsSync() expect(fs.existsSync(settingsPath())).toBe(true) const onDisk = JSON.parse(fs.readFileSync(settingsPath(), 'utf-8')) - expect(onDisk.showMinimap).toBe(DEFAULT_SETTINGS.showMinimap) + expect(onDisk.zoomSpeed).toBe(DEFAULT_SETTINGS.zoomSpeed) expect(onDisk.cliAgentReadEnabled).toBe(true) expect(onDisk.cliAgentControlEnabled).toBe(true) - expect(m.getSetting('showMinimap')).toBe(DEFAULT_SETTINGS.showMinimap) + expect(onDisk.showMinimap).toBeUndefined() }) it('loads an existing settings.json over defaults', async () => { @@ -66,7 +66,7 @@ describe('settingsFile', () => { const m = await freshModule() m.loadSettingsSync() expect(m.getSetting('terminalScrollback')).toBe(9000) - expect(m.getSetting('showMinimap')).toBe(DEFAULT_SETTINGS.showMinimap) + expect(m.getSetting('zoomSpeed')).toBe(DEFAULT_SETTINGS.zoomSpeed) }) it('validates setSetting and persists on sync flush', async () => { @@ -83,6 +83,21 @@ describe('settingsFile', () => { expect(onDisk.warnBeforeQuit).toBe(true) }) + it('rejects invalid enum, range, and structured values', async () => { + const m = await freshModule() + m.loadSettingsSync() + + expect(m.setSetting('canvasGridStyle', 'triangles' as never)).toBe(false) + expect(m.setSetting('uiScale', 4)).toBe(false) + expect(m.setSetting('agentHookInjection', null as never)).toBe(false) + expect(m.setSetting('customShortcuts', { newTerminal: { key: 't' } } as never)).toBe(false) + + expect(m.getSetting('canvasGridStyle')).toBe(DEFAULT_SETTINGS.canvasGridStyle) + expect(m.getSetting('uiScale')).toBe(DEFAULT_SETTINGS.uiScale) + expect(m.getSetting('agentHookInjection')).toEqual({}) + expect(m.getSetting('customShortcuts')).toEqual({}) + }) + it('resets a key back to its default', async () => { const m = await freshModule() m.loadSettingsSync() diff --git a/src/main/settingsFile.ts b/src/main/settingsFile.ts index 1dc4b12c..8d43bdee 100644 --- a/src/main/settingsFile.ts +++ b/src/main/settingsFile.ts @@ -18,9 +18,10 @@ import fsSync from 'fs' import log from './logger' import { isPlainObject } from './jsonUtils' -import { DEFAULT_SETTINGS } from '../shared/types' +import { DEFAULT_SETTINGS, SHORTCUT_ACTIONS } from '../shared/types' import type { AppSettings } from '../shared/types' import { createJsonStateFile } from './jsonStateFile' +import { validateTheme } from '../shared/theme' const SETTINGS_FILENAME = 'settings.json' @@ -33,7 +34,6 @@ const SETTINGS_SCHEMA: Record = { defaultShellPath: 'string', warnBeforeQuit: 'boolean', closeWorktreePanelsOnDelete: 'boolean', - worktreeSymlinkPaths: 'array', activeThemeId: 'string', systemLightThemeId: 'string', systemDarkThemeId: 'string', @@ -42,7 +42,6 @@ const SETTINGS_SCHEMA: Record = { editorFontFamily: 'string', uiScale: 'number', disableGpuRasterization: 'boolean', - showMinimap: 'boolean', zoomSpeed: 'number', autoFocusLargestVisibleNode: 'boolean', canvasGridStyle: 'string', @@ -75,18 +74,13 @@ const SETTINGS_SCHEMA: Record = { browserHomepage: 'string', browserSearchEngine: 'string', browserProxyUrl: 'string', - browserShowBookmarksBar: 'boolean', - browserShowTabSidebar: 'boolean', browserNewTabBehavior: 'string', terminalLinkOpenTarget: 'string', sidebarTintOpacity: 'number', showFileExplorerOnLaunch: 'boolean', showSkillsInWorkspaceOverview: 'boolean', - fileExclusions: 'array', notificationsEnabled: 'boolean', notifyOnlyWhenUnfocused: 'boolean', - telemetryNoticeAcknowledgedVersion: 'number', - onboardingCompleted: 'boolean', betaUpdatesEnabled: 'boolean', // Agent structured values. agentHookInjection: 'object', @@ -98,18 +92,46 @@ const SETTINGS_KEYS = Object.keys(SETTINGS_SCHEMA) as Array /** True if `value` matches the schema type expected for `key`. */ function valueMatchesSchema(key: keyof AppSettings, value: unknown): boolean { const expected = SETTINGS_SCHEMA[key] - if (expected === 'array') return Array.isArray(value) - // 'object' accepts a plain object or null; arrays are rejected so an array - // can't masquerade as an object. - if (expected === 'object') return typeof value === 'object' && !Array.isArray(value) - return typeof value === expected -} + if (expected === 'array' && !Array.isArray(value)) return false + if (expected === 'object' && !isPlainObject(value)) return false + if (expected !== 'array' && expected !== 'object' && typeof value !== expected) return false -// Array settings whose elements must all be strings. A malformed entry (non-array -// or any non-string element) falls back to the default [] rather than poisoning -// state with garbage that downstream consumers would have to defend against. -const STRING_ARRAY_KEYS = new Set([ -]) + if (key === 'canvasGridStyle') return value === 'dots' || value === 'lines' || value === 'none' + if (key === 'browserSearchEngine') return value === 'google' || value === 'duckDuckGo' || value === 'bing' || value === 'brave' + if (key === 'browserNewTabBehavior') return value === 'startPage' || value === 'homepage' + if (key === 'terminalLinkOpenTarget') return value === 'ask' || value === 'canvas' || value === 'external' + if (key === 'customThemes') return (value as unknown[]).every((theme) => validateTheme(theme).ok) + if (key === 'agentHookInjection') { + return Object.values(value as Record).every((workspace) => ( + isPlainObject(workspace) && Object.values(workspace).every((mode) => mode === 'auto' || mode === 'on' || mode === 'off') + )) + } + if (key === 'customShortcuts') { + const actions = new Set(SHORTCUT_ACTIONS) + return Object.entries(value as Record).every(([action, shortcut]) => ( + actions.has(action) && isPlainObject(shortcut) + && typeof shortcut.key === 'string' + && typeof shortcut.command === 'boolean' + && typeof shortcut.shift === 'boolean' + && typeof shortcut.option === 'boolean' + && typeof shortcut.control === 'boolean' + )) + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return false + const ranges: Partial> = { + editorFontSize: [8, 32], uiScale: [0.8, 1.5], zoomSpeed: [0.5, 3], + canvasBackgroundImageOpacity: [0, 1], terminalFontSize: [0, 32], + terminalScrollback: [100, 10_000], terminalScrollSpeed: [0.25, 3], + terminalContrast: [1, 21], sidebarTintOpacity: [0.3, 1], + } + const range = ranges[key] + if (range && (value < range[0] || value > range[1])) return false + if ((key === 'editorFontSize' || key === 'terminalFontSize' || key === 'terminalScrollback') && !Number.isInteger(value)) return false + } + return true +} /** Merge only known, type-correct keys from a parsed object into `target`. */ function mergeValidatedSettings(target: AppSettings, source: Record): void { @@ -120,10 +142,6 @@ function mergeValidatedSettings(target: AppSettings, source: Record typeof v === 'string')) { - log.warn('Settings schema mismatch: %s expected array of strings', key) - continue - } ;(target as unknown as Record)[key as string] = val } } diff --git a/src/main/store.test.ts b/src/main/store.test.ts index 0c7ca6bc..59bc84bd 100644 --- a/src/main/store.test.ts +++ b/src/main/store.test.ts @@ -158,12 +158,11 @@ describe('live theme background', () => { }) }) -test('newly connected runtimes receive current settings changed while disconnected', async () => { - await handlers.get(SETTINGS_SET)!({}, 'fileExclusions', ['new-exclusion']) +test('newly connected runtimes receive idle-suspend changes made while disconnected', async () => { await handlers.get(SETTINGS_SET)!({}, 'autoSuspendIdleTerminals', false) const runtime = { setExclusions: vi.fn().mockResolvedValue(undefined), setIdleSuspend: vi.fn().mockResolvedValue(undefined) } runtimeSettings.connected.forEach(notify => notify('local', runtime)) await Promise.resolve() - expect(runtime.setExclusions).toHaveBeenCalledWith(['new-exclusion']) + expect(runtime.setExclusions).not.toHaveBeenCalled() expect(runtime.setIdleSuspend).toHaveBeenCalledWith(false) }) diff --git a/src/main/store.ts b/src/main/store.ts index 74d860db..f1079031 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -16,7 +16,6 @@ import { SETTINGS_SET, SETTINGS_GET_ALL, SETTINGS_RESET, - SETTINGS_CHANGED, SETTINGS_OPEN_IN_EDITOR, SETTINGS_RELOADED, BOOT_SNAPSHOT_WRITE, @@ -80,10 +79,9 @@ import { grantFileAccess } from './ipc/pathValidation' import { recordPersistentGrant } from './grantedPathStore' import { computeThemeBootFields } from './themeBootCache' -type RuntimeSettings = Pick +type RuntimeSettings = Pick function applyRuntimeSettings(runtime: import('./runtime/types').Runtime, settings: Partial): void { - if (settings.fileExclusions !== undefined) void runtime.setExclusions(settings.fileExclusions).catch(() => {}) if (settings.autoSuspendIdleTerminals !== undefined) void runtime.setIdleSuspend(settings.autoSuspendIdleTerminals).catch(() => {}) } @@ -108,17 +106,11 @@ async function applySettingSideEffect(key: keyof AppSettings, value: unknown): P log.warn('Native shortcut menu rebuild failed: %O', err) } } - // fileExclusions has one live consumer (the FileExplorer tree) that listens on - // the SETTINGS_CHANGED invalidation channel and reloads. Broadcast it directly - // (the only key that uses this channel today). - if (key === 'fileExclusions') { - broadcastToAll(SETTINGS_CHANGED, key, value) - } - if (key === 'fileExclusions' || key === 'autoSuspendIdleTerminals') { + if (key === 'autoSuspendIdleTerminals') { try { const { runtimes } = await import('./runtime/runtimeManager') for (const id of runtimes.connectedIds()) { - applyRuntimeSettings(runtimes.resolve(id), { [key]: value }) + applyRuntimeSettings(runtimes.resolve(id), { autoSuspendIdleTerminals: value as boolean }) } } catch (err) { log.warn('Runtime settings forward failed: %O', err) @@ -271,7 +263,6 @@ export function registerHandlers(): void { // after settings change while a daemon is disconnected. void import('./runtime/runtimeManager').then(({ runtimes }) => { const replay = (_id: string, runtime: import('./runtime/types').Runtime) => applyRuntimeSettings(runtime, { - fileExclusions: getSettingFromFile('fileExclusions'), autoSuspendIdleTerminals: getSettingFromFile('autoSuspendIdleTerminals'), }) runtimes.onConnected(replay) diff --git a/src/main/t3Agent/T3HarnessManager.ts b/src/main/t3Agent/T3HarnessManager.ts index 772d60d8..8a713d98 100644 --- a/src/main/t3Agent/T3HarnessManager.ts +++ b/src/main/t3Agent/T3HarnessManager.ts @@ -302,8 +302,7 @@ export class T3HarnessManager { cookies.map(({ name, value }) => `${name}=${value}`).join('; '), method, payload) if (request.operation === 'save') { const allowed = new Set(['providers', 'providerInstances', 'enableProviderUpdateChecks', - 'providerHealthRefreshInterval', 'enableLegacyTokenStreaming', 'sidebarAutoSettleAfterDays', - 'sidebarAutoSettleOnMerge', 'textGenerationModelSelection']) + 'sidebarAutoSettleAfterDays', 'sidebarAutoSettleOnMerge', 'textGenerationModelSelection']) const patch = request.patch ?? {} if (Object.keys(patch).some((field) => !allowed.has(field))) throw new Error('Unsupported agent setting') await call('server.updateSettings', { patch }) diff --git a/src/main/t3Agent/providerProfile.test.ts b/src/main/t3Agent/providerProfile.test.ts index c220b93e..8f47a91a 100644 --- a/src/main/t3Agent/providerProfile.test.ts +++ b/src/main/t3Agent/providerProfile.test.ts @@ -32,7 +32,12 @@ describe('T3 provider profile', () => { }) it('removes stale provider values when the sparse global profile resets them', () => { - expect(applyProviderProfile({ providers: { codex: {} }, providerInstances: { work: {} } }, {})).toEqual({ + expect(applyProviderProfile({ + providers: { codex: {} }, + providerInstances: { work: {} }, + providerHealthRefreshInterval: 10, + enableLegacyTokenStreaming: true, + }, {})).toEqual({ defaultThreadEnvMode: 'local', enableAgentBrowserAccess: false, }) diff --git a/src/main/t3Agent/providerProfile.ts b/src/main/t3Agent/providerProfile.ts index 9aa47b36..4bba833e 100644 --- a/src/main/t3Agent/providerProfile.ts +++ b/src/main/t3Agent/providerProfile.ts @@ -5,7 +5,6 @@ const PROVIDER_SETTING_KEYS = [ 'providerInstances', 'usageLimitSources', 'enableProviderUpdateChecks', - 'providerHealthRefreshInterval', 'backgroundActivity', 'textGenerationModelSelection', 'sourceControlWriterModelSelection', @@ -51,6 +50,8 @@ export function applyProviderProfile( delete next[key] if (Object.hasOwn(profile, key)) next[key] = profile[key] } + delete next.providerHealthRefreshInterval + delete next.enableLegacyTokenStreaming next.defaultThreadEnvMode = 'local' next.enableAgentBrowserAccess = false return next diff --git a/src/main/uiStateStore.test.ts b/src/main/uiStateStore.test.ts new file mode 100644 index 00000000..9ae98ce3 --- /dev/null +++ b/src/main/uiStateStore.test.ts @@ -0,0 +1,43 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'cate-ui-state-test-')) + +vi.mock('electron', () => ({ + app: { getPath: () => userData }, + ipcMain: { handle: vi.fn() }, +})) +vi.mock('chokidar', () => ({ watch: () => ({ on: vi.fn(), close: vi.fn() }) })) + +const uiState = await import('./uiStateStore') + +beforeAll(() => { + fs.writeFileSync(path.join(userData, 'settings.json'), JSON.stringify({ + telemetryNoticeAcknowledgedVersion: 2, + onboardingCompleted: true, + })) + uiState.loadUIStateSync() +}) + +afterAll(() => { + fs.rmSync(userData, { recursive: true, force: true }) +}) + +describe('uiStateStore lifecycle migration', () => { + it('moves legacy lifecycle flags out of settings without replaying onboarding', () => { + uiState.migrateLegacyLifecycleState(path.join(userData, 'settings.json')) + expect(uiState.getUIStateSync('telemetryNoticeAcknowledgedVersion')).toBe(2) + expect(uiState.getUIStateSync('onboardingCompleted')).toBe(true) + }) + + it('does not overwrite lifecycle values already present in ui-state.json', () => { + uiState.setUIStateFromMain('telemetryNoticeAcknowledgedVersion', 3) + uiState.setUIStateFromMain('onboardingCompleted', false) + uiState.flushUIStateSync() + uiState.migrateLegacyLifecycleState(path.join(userData, 'settings.json')) + expect(uiState.getUIStateSync('telemetryNoticeAcknowledgedVersion')).toBe(3) + expect(uiState.getUIStateSync('onboardingCompleted')).toBe(false) + }) +}) diff --git a/src/main/uiStateStore.ts b/src/main/uiStateStore.ts index ad4388e5..0547258e 100644 --- a/src/main/uiStateStore.ts +++ b/src/main/uiStateStore.ts @@ -1,11 +1,12 @@ // ============================================================================= -// uiStateStore — transient, cosmetic UI placement (minimap button corner), -// persisted to `/ui-state.json` via ./jsonStateFile. Kept separate -// from settings.json so the user-facing settings file stays focused on -// preferences. Renderer reads it once on launch and writes single keys back. +// uiStateStore — renderer presentation and lifecycle state, persisted to +// `/ui-state.json` via ./jsonStateFile. Kept separate from +// settings.json so the user-facing settings file stays focused on preferences. +// Renderer reads it once on launch and writes single keys back. // ============================================================================= import { ipcMain } from 'electron' +import fsSync from 'fs' import { createJsonStateFile } from './jsonStateFile' import { isPlainObject } from './jsonUtils' import { DEFAULT_UI_STATE } from '../shared/types' @@ -21,15 +22,61 @@ const store = createJsonStateFile({ const o = isPlainObject(parsed) ? parsed : {} return { minimapButtonCorner: CORNERS.has(o.minimapButtonCorner as string) ? (o.minimapButtonCorner as UIState['minimapButtonCorner']) : defaults.minimapButtonCorner, + telemetryNoticeAcknowledgedVersion: typeof o.telemetryNoticeAcknowledgedVersion === 'number' && Number.isInteger(o.telemetryNoticeAcknowledgedVersion) && o.telemetryNoticeAcknowledgedVersion >= 0 + ? o.telemetryNoticeAcknowledgedVersion + : defaults.telemetryNoticeAcknowledgedVersion, + onboardingCompleted: typeof o.onboardingCompleted === 'boolean' ? o.onboardingCompleted : defaults.onboardingCompleted, } }, }) +export function loadUIStateSync(): void { + store.load() +} + +export function getUIStateSync(key: K): UIState[K] { + return store.get()[key] +} + +export function setUIStateFromMain(key: K, value: UIState[K]): void { + store.update((current) => ({ ...current, [key]: value })) +} + +/** Copy the two lifecycle flags from legacy settings.json once, before those + * obsolete keys disappear on the next settings write. */ +export function migrateLegacyLifecycleState(settingsPath: string): void { + const uiPath = store.getPath() + let rawUI: Record = {} + let legacy: Record = {} + try { + const parsed: unknown = JSON.parse(fsSync.readFileSync(uiPath, 'utf8')) + rawUI = isPlainObject(parsed) ? parsed : {} + } catch { /* first run */ } + try { + const parsed: unknown = JSON.parse(fsSync.readFileSync(settingsPath, 'utf8')) + legacy = isPlainObject(parsed) ? parsed : {} + } catch { return } + const patch: Partial = {} + if (!('telemetryNoticeAcknowledgedVersion' in rawUI) && typeof legacy.telemetryNoticeAcknowledgedVersion === 'number') { + patch.telemetryNoticeAcknowledgedVersion = legacy.telemetryNoticeAcknowledgedVersion + } + if (!('onboardingCompleted' in rawUI) && typeof legacy.onboardingCompleted === 'boolean') { + patch.onboardingCompleted = legacy.onboardingCompleted + } + if (Object.keys(patch).length > 0) store.update((current) => ({ ...current, ...patch })) +} + export function registerUIStateHandlers(): void { + store.load() ipcMain.handle(UI_STATE_GET_ALL, async () => store.get()) ipcMain.handle(UI_STATE_SET, async (_event, key: keyof UIState, value: unknown) => { - if (key !== 'minimapButtonCorner' || typeof value !== 'string' || !CORNERS.has(value)) return - store.update((cur) => ({ ...cur, [key]: value as UIState['minimapButtonCorner'] })) + if (key === 'minimapButtonCorner' && typeof value === 'string' && CORNERS.has(value)) { + store.update((cur) => ({ ...cur, minimapButtonCorner: value as UIState['minimapButtonCorner'] })) + } else if (key === 'telemetryNoticeAcknowledgedVersion' && typeof value === 'number' && Number.isInteger(value) && value >= 0) { + store.update((cur) => ({ ...cur, telemetryNoticeAcknowledgedVersion: value })) + } else if (key === 'onboardingCompleted' && typeof value === 'boolean') { + store.update((cur) => ({ ...cur, onboardingCompleted: value })) + } }) // Keep the in-memory copy fresh if the file is hand-edited (no broadcast — the // values are read per-window on launch; a live reload isn't worth the wiring). diff --git a/src/preload/index.ts b/src/preload/index.ts index be08eb47..52db4981 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -89,7 +89,6 @@ import { SETTINGS_SET, SETTINGS_GET_ALL, SETTINGS_RESET, - SETTINGS_CHANGED, SETTINGS_OPEN_IN_EDITOR, SETTINGS_RELOADED, UI_STATE_GET_ALL, @@ -729,10 +728,6 @@ contextBridge.exposeInMainWorld('electronAPI', { // Settings // --------------------------------------------------------------------------- - onSettingsChanged(callback: (key: keyof AppSettings, value: unknown) => void): () => void { - return createIpcListener(SETTINGS_CHANGED, callback) - }, - onSettingsReloaded(callback: (settings: AppSettings) => void): () => void { return createIpcListener(SETTINGS_RELOADED, callback) }, diff --git a/src/renderer/dialogs/PostUpdateFeedbackDialog.test.tsx b/src/renderer/dialogs/PostUpdateFeedbackDialog.test.tsx index 2c44dac9..659aa340 100644 --- a/src/renderer/dialogs/PostUpdateFeedbackDialog.test.tsx +++ b/src/renderer/dialogs/PostUpdateFeedbackDialog.test.tsx @@ -11,7 +11,7 @@ import { createRoot, type Root } from 'react-dom/client' import { act } from 'react' import { PostUpdateFeedbackDialog } from './PostUpdateFeedbackDialog' -import { useSettingsStore } from '../stores/settingsStore' +import { useUIStateStore } from '../stores/uiStateStore' import { TELEMETRY_NOTICE_VERSION } from '../../shared/types' let host: HTMLDivElement @@ -45,7 +45,7 @@ beforeEach(() => { // The dialog fetches the GitHub star count once visible; stub it so the effect // never hits the network in jsdom. vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({}) }))) - useSettingsStore.setState({ _loaded: true, telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION } as never) + useUIStateStore.setState({ _loaded: true, telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION } as never) }) afterEach(() => { @@ -57,20 +57,20 @@ afterEach(() => { describe('PostUpdateFeedbackDialog', () => { it('stays hidden while the telemetry notice is unacknowledged, even with a pending prompt', () => { - useSettingsStore.setState({ telemetryNoticeAcknowledgedVersion: 0 } as never) + useUIStateStore.setState({ telemetryNoticeAcknowledgedVersion: 0 } as never) act(() => root.render()) firePrompt({ fromVersion: '1.2.0', toVersion: '1.3.0' }) expect(host.textContent).toBe('') }) it('appears once the notice is acknowledged (notice goes first)', () => { - useSettingsStore.setState({ telemetryNoticeAcknowledgedVersion: 0 } as never) + useUIStateStore.setState({ telemetryNoticeAcknowledgedVersion: 0 } as never) act(() => root.render()) firePrompt({ fromVersion: '1.2.0', toVersion: '1.3.0' }) expect(host.textContent).toBe('') // The notice's acknowledgement flips the setting — the post-update dialog, // already holding the pending prompt, then reveals itself. - act(() => { useSettingsStore.setState({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION } as never) }) + act(() => { useUIStateStore.setState({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION } as never) }) expect(host.textContent).toContain('Rate this update') }) diff --git a/src/renderer/dialogs/PostUpdateFeedbackDialog.tsx b/src/renderer/dialogs/PostUpdateFeedbackDialog.tsx index ec5271ac..d800f1c0 100644 --- a/src/renderer/dialogs/PostUpdateFeedbackDialog.tsx +++ b/src/renderer/dialogs/PostUpdateFeedbackDialog.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { Star, Github as GithubLogo, Mail as Envelope, SquareArrowOutUpRight as ArrowSquareOut } from 'lucide-react' import heroImg from '../assets/dialog-hero.jpg' import { useEscapeKey } from '../lib/hooks/useEscapeKey' -import { useSettingsStore } from '../stores/settingsStore' +import { useUIStateStore } from '../stores/uiStateStore' import { TELEMETRY_NOTICE_VERSION } from '../../shared/types' import { findChangelogRelease, parseChangelog } from '../../shared/changelog' import changelogMarkdown from '../../../CHANGELOG.md?raw' @@ -33,8 +33,8 @@ export function PostUpdateFeedbackDialog() { // fully dormant — not rendered — so on an update it never // mounts behind the opaque notice. The pending prompt is held in main and // re-pulled, so it surfaces here the moment the notice is dismissed. - const loaded = useSettingsStore((s) => s._loaded) - const noticeAcknowledgedVersion = useSettingsStore((s) => s.telemetryNoticeAcknowledgedVersion) + const loaded = useUIStateStore((s) => s._loaded) + const noticeAcknowledgedVersion = useUIStateStore((s) => s.telemetryNoticeAcknowledgedVersion) const noticeReady = loaded && noticeAcknowledgedVersion >= TELEMETRY_NOTICE_VERSION const isFirstInstall = payload?.fromVersion === '' diff --git a/src/renderer/dialogs/WelcomeDialog.test.tsx b/src/renderer/dialogs/WelcomeDialog.test.tsx index f85728d9..0973f389 100644 --- a/src/renderer/dialogs/WelcomeDialog.test.tsx +++ b/src/renderer/dialogs/WelcomeDialog.test.tsx @@ -13,7 +13,7 @@ vi.mock('../lib/logger', () => ({ })) import { WelcomeDialog } from './WelcomeDialog' -import { useSettingsStore } from '../stores/settingsStore' +import { useUIStateStore } from '../stores/uiStateStore' import { TELEMETRY_NOTICE_VERSION } from '../../shared/types' let host: HTMLDivElement @@ -38,7 +38,7 @@ beforeEach(() => { trackLinkClick: vi.fn(), openExternalUrl: vi.fn(), } - useSettingsStore.setState({ _loaded: true, telemetryNoticeAcknowledgedVersion: 0 } as never) + useUIStateStore.setState({ _loaded: true, telemetryNoticeAcknowledgedVersion: 0 } as never) }) afterEach(() => { @@ -49,7 +49,7 @@ afterEach(() => { describe('WelcomeDialog', () => { it('is hidden once the current notice version is acknowledged', () => { - useSettingsStore.setState({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION } as never) + useUIStateStore.setState({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION } as never) act(() => root.render()) expect(host.textContent).toBe('') }) @@ -68,7 +68,7 @@ describe('WelcomeDialog', () => { clickButton((b) => b.textContent?.trim() === 'Continue') expect(acknowledge).toHaveBeenCalledTimes(1) act(() => { vi.advanceTimersByTime(350) }) - expect(useSettingsStore.getState().telemetryNoticeAcknowledgedVersion).toBe(TELEMETRY_NOTICE_VERSION) + expect(useUIStateStore.getState().telemetryNoticeAcknowledgedVersion).toBe(TELEMETRY_NOTICE_VERSION) vi.useRealTimers() }) }) diff --git a/src/renderer/dialogs/WelcomeDialog.tsx b/src/renderer/dialogs/WelcomeDialog.tsx index 20eb5b62..6ddb2a6c 100644 --- a/src/renderer/dialogs/WelcomeDialog.tsx +++ b/src/renderer/dialogs/WelcomeDialog.tsx @@ -11,7 +11,7 @@ import { useState } from 'react' import { Mail as EnvelopeSimple } from 'lucide-react' -import { useSettingsStore } from '../stores/settingsStore' +import { useUIStateStore } from '../stores/uiStateStore' import { CateLogo } from '../ui/CateLogo' import log from '../lib/logger' import headerImg from '../assets/welcome-header.jpg' @@ -40,8 +40,8 @@ function GithubMark({ size = 17 }: { size?: number }) { } export function WelcomeDialog() { - const acknowledgedVersion = useSettingsStore((s) => s.telemetryNoticeAcknowledgedVersion) - const loaded = useSettingsStore((s) => s._loaded) + const acknowledgedVersion = useUIStateStore((s) => s.telemetryNoticeAcknowledgedVersion) + const loaded = useUIStateStore((s) => s._loaded) const [saving, setSaving] = useState(false) const [exiting, setExiting] = useState(false) @@ -63,7 +63,7 @@ export function WelcomeDialog() { // this dialog and hands off to the tour (which fades in on its own), so the // transition is a soft dissolve rather than a harsh cut. window.setTimeout(() => { - useSettingsStore.setState({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION }) + useUIStateStore.setState({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION }) }, 320) } diff --git a/src/renderer/lib/runAction.ts b/src/renderer/lib/runAction.ts index ff20afa7..e5ec9ed1 100644 --- a/src/renderer/lib/runAction.ts +++ b/src/renderer/lib/runAction.ts @@ -15,7 +15,7 @@ import { getActiveCanvasPanelId, placementForActivePanel, } from '../stores/appStore' -import { useSettingsStore } from '../stores/settingsStore' +import { useUIStateStore } from '../stores/uiStateStore' import { isRemoteRuntimeConnection } from '../../shared/runtimeConnection' import { useUIStore } from '../stores/uiStore' import type { MenuActionId } from '../../shared/types' @@ -139,7 +139,7 @@ export async function runAction( case 'openUsage': useUIStore.getState().setShowUsage(true); break case 'skills': useUIStore.getState().setShowSkillsDialog(true); break case 'showTutorial': - useSettingsStore.getState().setSetting('onboardingCompleted', false) + useUIStateStore.getState().setUIState('onboardingCompleted', false) window.electronAPI?.trackFeatureUsed?.('onboarding_replayed') break case 'deleteRuntime': diff --git a/src/renderer/onboarding/OnboardingTour.test.tsx b/src/renderer/onboarding/OnboardingTour.test.tsx index e3846f2d..2cc732a6 100644 --- a/src/renderer/onboarding/OnboardingTour.test.tsx +++ b/src/renderer/onboarding/OnboardingTour.test.tsx @@ -14,14 +14,14 @@ vi.mock('../lib/logger', () => ({ import { OnboardingTour } from './OnboardingTour' import { ONBOARDING_STEPS } from './steps' -import { useSettingsStore } from '../stores/settingsStore' +import { useUIStateStore } from '../stores/uiStateStore' import { TELEMETRY_NOTICE_VERSION } from '../../shared/types' let host: HTMLDivElement let root: Root function setState(partial: Record): void { - act(() => { useSettingsStore.setState(partial as never) }) + act(() => { useUIStateStore.setState(partial as never) }) } function clickButton(match: (b: HTMLButtonElement) => boolean): void { @@ -34,15 +34,15 @@ beforeEach(() => { host = document.createElement('div') document.body.appendChild(host) root = createRoot(host) - // The settings store's setSetting fires settingsSet over IPC, and the tour + // The UI-state store persists completion over IPC, and the tour // reports usage — stub both so the real store action doesn't throw in jsdom. ;(window as unknown as { electronAPI: Record }).electronAPI = { ...(window as unknown as { electronAPI?: Record }).electronAPI, - settingsSet: vi.fn(() => Promise.resolve()), + uiStateSet: vi.fn(() => Promise.resolve()), trackFeatureUsed: vi.fn(), } // Fresh, consented, not-yet-onboarded state. - useSettingsStore.setState({ _loaded: true, telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION, onboardingCompleted: false } as never) + useUIStateStore.setState({ _loaded: true, telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION, onboardingCompleted: false } as never) }) afterEach(() => { @@ -76,14 +76,14 @@ describe('OnboardingTour', () => { clickButton((b) => b.textContent?.includes('Next') ?? false) } clickButton((b) => b.textContent?.includes('Get started') ?? false) - expect(useSettingsStore.getState().onboardingCompleted).toBe(true) + expect(useUIStateStore.getState().onboardingCompleted).toBe(true) expect(host.textContent).toBe('') }) it('skipping (the X) persists completion and dismisses', () => { act(() => root.render()) clickButton((b) => b.getAttribute('aria-label') === 'Skip tour') - expect(useSettingsStore.getState().onboardingCompleted).toBe(true) + expect(useUIStateStore.getState().onboardingCompleted).toBe(true) expect(host.textContent).toBe('') }) diff --git a/src/renderer/onboarding/OnboardingTour.tsx b/src/renderer/onboarding/OnboardingTour.tsx index 76f05a96..8ec1bd2e 100644 --- a/src/renderer/onboarding/OnboardingTour.tsx +++ b/src/renderer/onboarding/OnboardingTour.tsx @@ -5,7 +5,7 @@ // real piece of the UI (canvas, toolbar, sidebar) and spotlight it — dimming the // rest of the screen and floating the explanation card beside the highlighted // element. Shows once after the telemetry-consent step; replayable by resetting -// the `onboardingCompleted` setting (see the "Show Tutorial" command). +// onboarding UI state (see the "Show Tutorial" command). // // Visual language matches the dark dialogs (WelcomeDialog / // PostUpdateFeedbackDialog): dark cards, soft borders, blue accent. @@ -13,7 +13,7 @@ import { useCallback, useEffect, useLayoutEffect, useState } from 'react' import { ArrowLeft, ArrowRight, X } from 'lucide-react' -import { useSettingsStore } from '../stores/settingsStore' +import { useUIStateStore } from '../stores/uiStateStore' import { useUIStore } from '../stores/uiStore' import { ONBOARDING_STEPS, type OnboardingStep } from './steps' import { TELEMETRY_NOTICE_VERSION } from '../../shared/types' @@ -133,10 +133,10 @@ function clampBox(rect: Rect, pad: number): { left: number; top: number; width: } export function OnboardingTour() { - const loaded = useSettingsStore((s) => s._loaded) - const noticeAcknowledgedVersion = useSettingsStore((s) => s.telemetryNoticeAcknowledgedVersion) - const completed = useSettingsStore((s) => s.onboardingCompleted) - const setSetting = useSettingsStore((s) => s.setSetting) + const loaded = useUIStateStore((s) => s._loaded) + const noticeAcknowledgedVersion = useUIStateStore((s) => s.telemetryNoticeAcknowledgedVersion) + const completed = useUIStateStore((s) => s.onboardingCompleted) + const setUIState = useUIStateStore((s) => s.setUIState) const setShowCommandPalette = useUIStore((s) => s.setShowCommandPalette) const [step, setStep] = useState(0) @@ -186,14 +186,14 @@ export function OnboardingTour() { }, [active]) const finish = useCallback((reason: 'completed' | 'skipped') => { - setSetting('onboardingCompleted', true) + setUIState('onboardingCompleted', true) try { window.electronAPI?.trackFeatureUsed?.( reason === 'completed' ? 'onboarding_completed' : 'onboarding_skipped', { steps_seen: step + 1 }, ) } catch { /* noop */ } - }, [setSetting, step]) + }, [setUIState, step]) const next = useCallback(() => { if (step >= ONBOARDING_STEPS.length - 1) finish('completed') diff --git a/src/renderer/panels/BrowserPanel.component.test.tsx b/src/renderer/panels/BrowserPanel.component.test.tsx index cfcecda1..6f237e36 100644 --- a/src/renderer/panels/BrowserPanel.component.test.tsx +++ b/src/renderer/panels/BrowserPanel.component.test.tsx @@ -56,7 +56,7 @@ beforeEach(() => { useBrowserStore.setState({ bookmarks: [], recordVisit: vi.fn(), toggleBookmark: vi.fn(), querySuggestions: vi.fn(() => []) }) useSettingsStore.setState({ browserHomepage: '', browserSearchEngine: 'google', browserProxyUrl: '', - browserNewTabBehavior: 'startPage', browserShowTabSidebar: false, setSetting: vi.fn(), + browserNewTabBehavior: 'startPage', setSetting: vi.fn(), }) Object.assign(window, { electronAPI: { browserControl, browserSetProxy: vi.fn(async () => undefined), onBrowserShortcut: vi.fn(() => () => undefined), diff --git a/src/renderer/settings/AgentHooksSettings.tsx b/src/renderer/settings/AgentHooksSettings.tsx index a5e9aed0..c84c44d5 100644 --- a/src/renderer/settings/AgentHooksSettings.tsx +++ b/src/renderer/settings/AgentHooksSettings.tsx @@ -75,12 +75,9 @@ export function AgentHooksSettings() { return ( -

- Show agent activity and session status in Cate. These preferences apply to this workspace. -

{agents === null && } {error &&

Could not check agent hooks. Reopen settings to try again.

} - {!!agents?.length &&
+ {!!agents?.length &&
{agents.map((a) => { const evaluation = evaluateAgentCliHooks(a, overrides) const mode: AgentHookMode = evaluation.mode @@ -92,7 +89,7 @@ export function AgentHooksSettings() {
{logo && } @@ -114,17 +111,6 @@ export function AgentHooksSettings() { ) })}
} -

- Auto enables hooks where an agent is already configured. Choose On to install them explicitly. - Changes take effect in new terminals. -

-
- How hooks work -

- Cate adds git-ignored hook files to the workspace so agent CLIs can report their activity. - Approval prompts remain in the terminal for CLIs without a dedicated permission event. -

-
) } diff --git a/src/renderer/settings/AgentProviderConfiguration.tsx b/src/renderer/settings/AgentProviderConfiguration.tsx index 3f334ed3..97b58625 100644 --- a/src/renderer/settings/AgentProviderConfiguration.tsx +++ b/src/renderer/settings/AgentProviderConfiguration.tsx @@ -28,7 +28,7 @@ interface Instance { function SettingsDisclosure({ title, description, children }: { title: string; description: string; children: ReactNode }) { const { query } = useSettingsSearch() - return
+ return
@@ -36,7 +36,7 @@ function SettingsDisclosure({ title, description, children }: { title: string; d {description} -
{children}
+
{children}
} @@ -52,6 +52,7 @@ export function AgentProviderConfiguration({ workspaceId, cwd, onChanged, authen const [message, setMessage] = useState('') const [newDriver, setNewDriver] = useState('codex') const [dirty, setDirty] = useState(false) + const [confirmRemoveAccount, setConfirmRemoveAccount] = useState(null) const operate = useCallback(async (operation: 'read' | 'save' | 'refresh' | 'update', extra: Record = {}) => { if (!cwd || !workspaceId) return setBusy(true); setError(''); setMessage('') @@ -125,7 +126,7 @@ export function AgentProviderConfiguration({ workspaceId, cwd, onChanged, authen })}
-
+

Provider settings

{draft.displayName || (drivers.includes(selected) ? names[draft.driver] : `${names[draft.driver] || draft.driver} · ${selected}`)}

@@ -165,13 +166,17 @@ export function AgentProviderConfiguration({ workspaceId, cwd, onChanged, authen void operate('save', { patch: { providerInstances: { ...settings.providerInstances, [id]: { driver: newDriver, enabled: true, config: {} } } } }) }}>Add account
{!drivers.includes(selected) && { + if (confirmRemoveAccount !== selected) { + setConfirmRemoveAccount(selected) + return + } const remaining = { ...settings.providerInstances }; delete remaining[selected]; setSelected('codex') + setConfirmRemoveAccount(null) void operate('save', { patch: { providerInstances: remaining } }) - }}>Remove selected account} + }}>{confirmRemoveAccount === selected ? 'Confirm removal' : 'Remove selected account'}} - {([['enableProviderUpdateChecks', 'Check for provider updates'], ['enableLegacyTokenStreaming', 'Legacy token streaming'], ['sidebarAutoSettleOnMerge', 'Settle chats after merge']] as const).map(([key, label]) => void operate('save', { patch: { [key]: value } })} />)} - void operate('save', { patch: { sidebarAutoSettleAfterDays: value === 'never' ? null : Number(value) } })} options={[{ value: 'never', label: 'Never' }, ...[1, 3, 7, 14, 30].map((days) => ({ value: String(days), label: `After ${days} days` }))]} /> store.setSetting('browserSearchEngine', v as BrowserSearchEngine)} @@ -55,7 +56,7 @@ export function BrowserSettings() { ]} /> - + store.setSetting('showWorktreeTerritory', v)} /> - +