Pin the UI locale to the language setting and add theme + language selectors with a dark palette
This commit is contained in:
1 parent
b30b9a3bc1
commit
d404e1e72a
18 files changed
+176
-11
No files matched your search
@@ -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)));
|
||||
|
||||
@@ -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';
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 <div className="app-shell" style={{ display: "grid", placeItems: "center" }} />;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
</aside>
|
||||
<div className="main" style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="content">
|
||||
{tab === "general" && <WorkspacePanel organization={organization} workspaces={workspaces} projects={snapshot.projects.filter((p) => p.orgId === orgId)} orgId={orgId} actions={actions} />}
|
||||
{tab === "general" && <WorkspacePanel organization={organization} workspaces={workspaces} projects={snapshot.projects.filter((p) => p.orgId === orgId)} orgId={orgId} uiPreferences={snapshot.uiPreferences} actions={actions} />}
|
||||
{tab === "providers" && (
|
||||
<ProvidersPanel
|
||||
orgId={orgId}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { OrganizationRecord, ProjectRecord, WorkspaceRecord } from "../../../../shared/domain";
|
||||
import type {
|
||||
OrganizationRecord,
|
||||
ProjectRecord,
|
||||
UiLanguage,
|
||||
UiPreferences,
|
||||
UiTheme,
|
||||
WorkspaceRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { Button, Field, Icon, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
@@ -17,12 +24,14 @@ export function WorkspacePanel({
|
||||
workspaces,
|
||||
projects,
|
||||
orgId,
|
||||
uiPreferences,
|
||||
actions,
|
||||
}: {
|
||||
organization: OrganizationRecord;
|
||||
workspaces: WorkspaceRecord[];
|
||||
projects: ProjectRecord[];
|
||||
orgId: string;
|
||||
uiPreferences: UiPreferences;
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -63,6 +72,37 @@ export function WorkspacePanel({
|
||||
<KeyValue label="Description" value={organization.description || "—"} />
|
||||
</div>
|
||||
|
||||
<div className="section-label" style={{ marginBottom: 8 }}>Appearance</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 240px))", gap: 12, marginBottom: 28 }}>
|
||||
<Field label="Theme">
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Theme"
|
||||
value={uiPreferences.theme}
|
||||
onChange={(event) =>
|
||||
void actions.run(() => window.agentCompany.setUiPreferences({ theme: event.target.value as UiTheme }))
|
||||
}
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Language">
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Language"
|
||||
value={uiPreferences.language}
|
||||
onChange={(event) =>
|
||||
void actions.run(() => window.agentCompany.setUiPreferences({ language: event.target.value as UiLanguage }))
|
||||
}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="row reveal-row" style={{ marginBottom: 8 }}>
|
||||
<div className="section-label">Workspaces</div>
|
||||
<span className="reveal" style={{ marginLeft: "auto" }}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ import type {
|
||||
SearchInput,
|
||||
SendMessageInput,
|
||||
SetActiveOrganizationInput,
|
||||
SetUiPreferencesInput,
|
||||
SetFilePinnedInput,
|
||||
SetFeatureFlagInput,
|
||||
SetLegalHoldStateInput,
|
||||
@@ -105,6 +106,7 @@ export interface DesktopApi {
|
||||
loadSnapshot(): Promise<DesktopResult<AppSnapshot>>;
|
||||
bootstrapOnboarding(input: BootstrapOnboardingInput): Promise<DesktopResult<BootstrapOnboardingSummary>>;
|
||||
setActiveOrganization(input: SetActiveOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setUiPreferences(input: SetUiPreferencesInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteOrganization(input: DeleteOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createOrganization(input: CreateOrganizationInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createAgent(input: CreateAgentInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user