diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 7361bfe3..2ed033f5 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -1,4 +1,4 @@ -import { dialog, ipcMain, shell, type BrowserWindow } from "electron"; +import { app, dialog, ipcMain, shell, type BrowserWindow } from "electron"; import type { DatabaseConnection } from "../database/connection"; import { IPC_CHANNELS } from "../../shared/ipc"; import { fail, ok, type DesktopResult } from "../../shared/result"; @@ -107,6 +107,19 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.createOrganization, (_event, input) => mutate(() => organization.createOrganization(input))); ipcMain.handle(IPC_CHANNELS.setActiveOrganization, (_event, input) => mutate(() => organization.setActiveOrganization(input.orgId))); + ipcMain.handle(IPC_CHANNELS.setUiPreferences, (_event, input) => { + const before = snapshot.load().uiPreferences.language; + const result = mutate(() => organization.updateUiPreferences(input)); + if (result.ok && result.data.uiPreferences.language !== before) { + // Chromium's locale is fixed at launch; a language change needs a + // fresh process to take effect. + setTimeout(() => { + app.relaunch(); + app.quit(); + }, 150); + } + return result; + }); ipcMain.handle(IPC_CHANNELS.deleteOrganization, (_event, input) => mutate(() => organization.deleteOrganization(input.orgId))); ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input))); ipcMain.handle(IPC_CHANNELS.updateAgent, (_event, input) => mutate(() => organization.updateAgent(input))); diff --git a/src/main/database/migrations/048-ui-preferences.ts b/src/main/database/migrations/048-ui-preferences.ts new file mode 100644 index 00000000..4329d840 --- /dev/null +++ b/src/main/database/migrations/048-ui-preferences.ts @@ -0,0 +1,10 @@ +/** + * App-level UI preferences on the single app_state row. Theme drives the + * renderer palette; language pins Chromium's UI locale (form-control strings + * were following the OS locale and leaking Chinese into the English UI, so + * the default is English rather than system). + */ +export const uiPreferencesSchema = ` + alter table app_state add column theme text not null default 'system'; + alter table app_state add column language text not null default 'en'; +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 44c39ea5..559aefc9 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -46,6 +46,7 @@ import { mentionWakeStateSchema } from "./migrations/044-mention-wake-state"; import { connectorGrantsSchema } from "./migrations/045-connector-grants"; import { composioKeyPurgeSchema } from "./migrations/046-composio-key-purge"; import { authConfigPurgeSchema } from "./migrations/047-auth-config-purge"; +import { uiPreferencesSchema } from "./migrations/048-ui-preferences"; interface Migration { version: number; @@ -101,6 +102,7 @@ const MIGRATIONS: Migration[] = [ { version: 45, name: "connector_grants", sql: connectorGrantsSchema }, { version: 46, name: "composio_key_purge", sql: composioKeyPurgeSchema }, { version: 47, name: "auth_config_purge", sql: authConfigPurgeSchema }, + { version: 48, name: "ui_preferences", sql: uiPreferencesSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/index.ts b/src/main/index.ts index b4fadd30..1b6fbd96 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,9 +18,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); if (!app.requestSingleInstanceLock()) { app.quit(); } else { + const profileDir = process.env.AGENTCOMPANY_NEW_PROFILE_DIR ?? join(app.getPath("userData"), "profile"); + // The database opens before ready: Chromium's UI locale (form-control + // strings, date formatting) is only settable pre-ready, and it follows the + // stored language preference instead of the OS locale. + const connection = new DatabaseConnection(join(profileDir, "agentcompany.db")); + app.commandLine.appendSwitch("lang", storedLanguage(connection.db) === "zh" ? "zh-CN" : "en-US"); + app.whenReady().then(() => { - const profileDir = process.env.AGENTCOMPANY_NEW_PROFILE_DIR ?? join(app.getPath("userData"), "profile"); - const connection = new DatabaseConnection(join(profileDir, "agentcompany.db")); connection.transaction(() => recoverInterruptedWork(connection.db)); const win = createMainWindow({ preloadPath: join(__dirname, "../preload/index.js"), @@ -60,6 +65,11 @@ if (!app.requestSingleInstanceLock()) { }); } +function storedLanguage(db: DatabaseSync): string { + const row = db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined; + return row?.language ?? "en"; +} + /** * Loads the on-disk skill library for every organization at startup. One * organization's failure (a broken workspace path, say) must not block the diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 2c41da14..c59cc041 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -47,6 +47,18 @@ export class OrganizationService { }); } + updateUiPreferences(input: { theme?: "system" | "light" | "dark"; language?: "en" | "zh" }): void { + const current = this.db.prepare("select theme, language from app_state where id = 1") + .get() as { theme: string; language: string } | undefined; + const theme = input.theme ?? current?.theme ?? "system"; + const language = input.language ?? current?.language ?? "en"; + this.db.prepare(` + insert into app_state(id, active_org_id, theme, language, updated_at) + values (1, null, ?, ?, ?) + on conflict(id) do update set theme = excluded.theme, language = excluded.language, updated_at = excluded.updated_at + `).run(theme, language, new Date().toISOString()); + } + deleteOrganization(orgId: string): void { const row = this.db.prepare("select id from organizations where id = ?") .get(orgId) as { id: string } | undefined; diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index d475dc2d..74db2c13 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -87,6 +87,7 @@ export class SnapshotService { return { activeOrgId, + uiPreferences: this.resolveUiPreferences(), organizations, employees: list(this.db, "employees", "created_at asc"), agents, @@ -178,6 +179,14 @@ export class SnapshotService { if (stored && known.includes(stored)) return stored; return known[0] ?? null; } + + private resolveUiPreferences(): AppSnapshot["uiPreferences"] { + const row = this.db.prepare("select theme, language from app_state where id = 1") + .get() as { theme: string; language: string } | undefined; + const theme = row?.theme === "light" || row?.theme === "dark" ? row.theme : "system"; + const language = row?.language === "zh" ? "zh" : "en"; + return { theme, language }; + } } function buildProviderRateLimits( diff --git a/src/preload/index.ts b/src/preload/index.ts index 7726f1df..cb3eccc7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ const api: DesktopApi = { bootstrapOnboarding: (input) => invoke(IPC_CHANNELS.bootstrapOnboarding, input), createOrganization: (input) => invoke(IPC_CHANNELS.createOrganization, input), setActiveOrganization: (input) => invoke(IPC_CHANNELS.setActiveOrganization, input), + setUiPreferences: (input) => invoke(IPC_CHANNELS.setUiPreferences, input), deleteOrganization: (input) => invoke(IPC_CHANNELS.deleteOrganization, input), createAgent: (input) => invoke(IPC_CHANNELS.createAgent, input), updateAgent: (input) => invoke(IPC_CHANNELS.updateAgent, input), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 1be67c2e..fb81d996 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Shell, type RouteId } from "../shared/Shell"; import { Onboarding } from "./Onboarding"; import { useSnapshot } from "./useSnapshot"; @@ -22,6 +22,19 @@ export function App() { // would flip to the shell mid-onboarding. const [finishingSetup, setFinishingSetup] = useState(false); + const theme = snapshot?.uiPreferences.theme ?? "system"; + useEffect(() => { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => { + const resolved = theme === "system" ? (media.matches ? "dark" : "light") : theme; + document.documentElement.dataset.theme = resolved; + }; + apply(); + if (theme !== "system") return; + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [theme]); + if (!snapshot) { return
; } diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 575141ca..1b41f86d 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -54,7 +54,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {