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) {
- {tab === "general" && p.orgId === orgId)} orgId={orgId} actions={actions} />} + {tab === "general" && p.orgId === orgId)} orgId={orgId} uiPreferences={snapshot.uiPreferences} actions={actions} />} {tab === "providers" && (
+
Appearance
+
+ + + + + + +
+
Workspaces
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 2ab38c3e..187e11a8 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -63,6 +63,44 @@ --motion-slow: 280ms; --ease-out: cubic-bezier(0.25, 1, 0.5, 1); --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + + /* Primary action β€” inverts against the theme */ + --btn-primary-bg: var(--text-1); + --btn-primary-fg: #ffffff; + --btn-primary-hover: #2c2c2c; + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #131313; + --panel: #1b1b1b; + --panel-2: #212121; + --panel-3: #2a2a2a; + --hover: #232323; + --border: #2c2c2c; + --border-strong: #3a3a3a; + --border-soft: #242424; + + --text-1: #ededed; + --text-2: #a5a5a5; + --text-3: #7c7c7c; + --text-4: #4f4f4f; + + --accent: #7c88e8; + --accent-soft: #262a4a; + --green: #45b06d; + --green-soft: #16301f; + --amber: #d99a3d; + --amber-soft: #33270f; + --red: #e05252; + + --shadow-sm: 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 1px 3px rgba(0, 0, 0, 0.4), 0 6px 24px rgba(0, 0, 0, 0.35); + + --btn-primary-bg: #ededed; + --btn-primary-fg: #131313; + --btn-primary-hover: #ffffff; + color-scheme: dark; } * { box-sizing: border-box; } diff --git a/src/renderer/src/styles/components.css b/src/renderer/src/styles/components.css index 2d9e5e69..433ae033 100644 --- a/src/renderer/src/styles/components.css +++ b/src/renderer/src/styles/components.css @@ -33,8 +33,8 @@ } .button:hover { background: var(--panel-2); } .button:active:not(:disabled) { transform: scale(0.97); } -.button.primary { background: var(--text-1); color: #fff; border-color: var(--text-1); } -.button.primary:hover { background: #2c2c2c; } +.button.primary { background: var(--btn-primary-bg); color: var(--btn-primary-fg); border-color: var(--btn-primary-bg); } +.button.primary:hover { background: var(--btn-primary-hover); } .button.ghost { border-color: transparent; background: transparent; color: var(--text-2); } .button.ghost:hover { background: var(--hover); color: var(--text-1); } .button.small { height: 28px; padding: 0 var(--sp-2); font-size: var(--fs-caption); } diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index e5200769..72ef894b 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -1,9 +1,14 @@ -import type { ApprovalState, IdentitySource, PrincipalType, RiskLevel, RoleKey, TaskPriority, TaskStatus } from "../domain"; +import type { ApprovalState, IdentitySource, PrincipalType, RiskLevel, RoleKey, TaskPriority, TaskStatus, UiLanguage, UiTheme } from "../domain"; export interface SetActiveOrganizationInput { orgId: string; } +export interface SetUiPreferencesInput { + theme?: UiTheme; + language?: UiLanguage; +} + export interface DeleteOrganizationInput { orgId: string; } diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 7917829d..cbf6ab0d 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -71,6 +71,7 @@ import type { SearchInput, SendMessageInput, SetActiveOrganizationInput, + SetUiPreferencesInput, SetFilePinnedInput, SetFeatureFlagInput, SetLegalHoldStateInput, @@ -105,6 +106,7 @@ export interface DesktopApi { loadSnapshot(): Promise>; bootstrapOnboarding(input: BootstrapOnboardingInput): Promise>; setActiveOrganization(input: SetActiveOrganizationInput): Promise>; + setUiPreferences(input: SetUiPreferencesInput): Promise>; deleteOrganization(input: DeleteOrganizationInput): Promise>; createOrganization(input: CreateOrganizationInput): Promise>; createAgent(input: CreateAgentInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index e76e0f76..dbf80015 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -14,7 +14,7 @@ export type { MigrationCutoverEventRecord, MigrationReconciliationItemRecord, Ro export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain"; export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain"; export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain"; -export type { AppSnapshot } from "./snapshot-domain"; +export type { AppSnapshot, UiLanguage, UiPreferences, UiTheme } from "./snapshot-domain"; export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const; export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index c12c067e..7c182679 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -2,6 +2,7 @@ export const IPC_CHANNELS = { loadSnapshot: "agentcompany:load-snapshot", bootstrapOnboarding: "agentcompany:bootstrap-onboarding", setActiveOrganization: "agentcompany:set-active-organization", + setUiPreferences: "agentcompany:set-ui-preferences", deleteOrganization: "agentcompany:delete-organization", createOrganization: "agentcompany:create-organization", createAgent: "agentcompany:create-agent", diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index a93c1f11..3a0caff3 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -62,8 +62,17 @@ import type { SocialAuditRecord, } from "./social-domain"; +export type UiTheme = "system" | "light" | "dark"; +export type UiLanguage = "en" | "zh"; + +export interface UiPreferences { + theme: UiTheme; + language: UiLanguage; +} + export interface AppSnapshot { activeOrgId: string | null; + uiPreferences: UiPreferences; organizations: OrganizationRecord[]; employees: EmployeeRecord[]; agents: AgentRecord[]; diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 75f08d71..579622dc 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48]); connection.close(); });