From dc3127c26fc7fcde92da802b268b6e4ffd0ae5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 04:53:25 -0400 Subject: [PATCH] Q174: choosing a language no longer restarts the app; the restart waits for the user Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/index.ts | 2 + src/main/app/ipc-handlers.ts | 22 ++++--- src/preload/index.ts | 2 + src/renderer/src/i18n/zh.ts | 6 ++ .../src/pages/settings/LanguageField.tsx | 58 +++++++++++++++++++ .../src/pages/settings/WorkspacePanel.tsx | 17 +----- src/shared/desktop-api.ts | 4 ++ src/shared/ipc.ts | 2 + tests/e2e/ceo-chat.spec.ts | 9 +++ 9 files changed, 95 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/pages/settings/LanguageField.tsx diff --git a/src/main/app/ipc-contracts/index.ts b/src/main/app/ipc-contracts/index.ts index d48feb3e..f95820d4 100644 --- a/src/main/app/ipc-contracts/index.ts +++ b/src/main/app/ipc-contracts/index.ts @@ -13,6 +13,8 @@ const noInput = z.undefined(); /** The runtime contract of each IPC call; a method without a schema, or a schema that disagrees with its type, fails the typecheck. */ export const IPC_CONTRACTS: { [K in InvokedMethod]: z.ZodType> } = { loadSnapshot: noInput, + relaunchApp: noInput, + runningSessionCount: noInput, pickDirectory: noInput, listConnectorCatalog: noInput, windowMinimize: noInput, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 9acab196..95b70ae3 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -140,18 +140,16 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("createOrganization", (input) => mutate(() => organization.createOrganization(input))); handle("setActiveOrganization", (input) => mutate(() => organization.setActiveOrganization(input.orgId))); - handle("setUiPreferences", (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; + handle("setUiPreferences", (input) => mutate(() => organization.updateUiPreferences(input))); + handle("runningSessionCount", () => ok((deps.connection.db.prepare("select count(*) as n from task_runs where state = 'running'").get() as { n: number }).n)); + // Chromium's locale is fixed at launch; the reply goes out before the process exits. + handle("relaunchApp", () => { + console.info("[app] relaunch requested by the user"); + setTimeout(() => { + app.relaunch(); + app.quit(); + }, 150); + return ok(null); }); handle("deleteOrganization", (input) => mutate(() => organization.deleteOrganization(input.orgId))); handle("createAgent", (input) => mutate(() => organization.createAgent(input))); diff --git a/src/preload/index.ts b/src/preload/index.ts index dd3d4509..c707f8a3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,8 @@ function invoke(channel: string, payload?: unknown): Promise { const api: DesktopApi = { loadSnapshot: () => invoke(IPC_CHANNELS.loadSnapshot), + relaunchApp: () => invoke(IPC_CHANNELS.relaunchApp), + runningSessionCount: () => invoke(IPC_CHANNELS.runningSessionCount), bootstrapOnboarding: (input) => invoke(IPC_CHANNELS.bootstrapOnboarding, input), createOrganization: (input) => invoke(IPC_CHANNELS.createOrganization, input), setActiveOrganization: (input) => invoke(IPC_CHANNELS.setActiveOrganization, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 1411c10f..e28ae567 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -696,6 +696,12 @@ export const zh: Record = { "Resets {date} (UTC)": "{date} 重置(UTC)", "Unsaved": "未保存", "Working language": "工作语言", + "{language} applies after a restart.": "{language} 将在重启后生效。", + "Restart now": "立即重启", + "Restart to switch language?": "重启以切换语言?", + "Text you typed but have not sent or saved is lost.": "已输入但尚未发送或保存的文字会丢失。", + "Running agent sessions: {count}. They stop now; their tasks stay open and start a new session when next queued, by heartbeat or Run, and a chat reply in progress is not sent.": "运行中的 Agent 会话:{count}。它们会立即停止;其任务保持未完成,并在下次通过心跳或“运行”排队时开始新会话,进行中的聊天回复不会发出。", + "Counting running agent sessions…": "正在统计运行中的 Agent 会话…", "Company default ({language})": "公司默认({language})", "Agents' working language": "Agent 工作语言", "Agents write messages, tasks, documents and deliverables in it. The display language does not change it, and a task can set its own.": "Agent 用它撰写消息、任务、文档和交付物。显示语言不会改变它,单个任务也可以单独设置。", diff --git a/src/renderer/src/pages/settings/LanguageField.tsx b/src/renderer/src/pages/settings/LanguageField.tsx new file mode 100644 index 00000000..d5eaeebd --- /dev/null +++ b/src/renderer/src/pages/settings/LanguageField.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import type { UiLanguage, UiPreferences } from "../../../../shared/domain"; +import type { SnapshotActions } from "../../app/useSnapshot"; +import { Dialog } from "../../shared/Dialog"; +import { Button, Field } from "../../shared/ui"; +import { t } from "../../i18n/i18n"; + +const NAMES: Record = { en: "English", zh: "中文" }; + +/** The display language is fixed per launch, so a choice waits for a restart the user starts after seeing what it interrupts. */ +export function LanguageField({ uiPreferences, actions }: { uiPreferences: UiPreferences; actions: SnapshotActions }) { + const [confirming, setConfirming] = useState(false); + const [running, setRunning] = useState(null); + const [error, setError] = useState(null); + const confirm = () => { + setRunning(null); + setError(null); + setConfirming(true); + void window.agentCompany.runningSessionCount().then((result) => { + if (result.ok) setRunning(result.data); + else setError(result.error.message); + }); + }; + const pending = uiPreferences.language !== window.agentCompany.language; + const restart = () => { + setError(null); + void window.agentCompany.relaunchApp().then((result) => { if (!result.ok) setError(result.error.message); }); + }; + return ( + + + {t("Changes only what you see; agents keep the company's working language.")} + {pending && ( + + {t("{language} applies after a restart.", { language: NAMES[uiPreferences.language] })} + + + )} + setConfirming(false)} onSubmit={(event) => { event.preventDefault(); restart(); }}> +
    +
  • {t("Text you typed but have not sent or saved is lost.")}
  • +
  • {running === null + ? t("Counting running agent sessions…") + : t("Running agent sessions: {count}. They stop now; their tasks stay open and start a new session when next queued, by heartbeat or Run, and a chat reply in progress is not sent.", { count: running })}
  • +
+ {error &&
{error}
} +
+
+ ); +} diff --git a/src/renderer/src/pages/settings/WorkspacePanel.tsx b/src/renderer/src/pages/settings/WorkspacePanel.tsx index 450b714b..307ebd4d 100644 --- a/src/renderer/src/pages/settings/WorkspacePanel.tsx +++ b/src/renderer/src/pages/settings/WorkspacePanel.tsx @@ -1,3 +1,4 @@ +import { LanguageField } from "./LanguageField"; import { WorkspaceRow } from "./WorkspaceRow"; import { CompanyDialog } from "./CompanyDialog"; import { ScopeNote } from "./ScopeNote"; @@ -5,7 +6,6 @@ import { useState, type FormEvent } from "react"; import type { OrganizationRecord, ProjectRecord, - UiLanguage, UiPreferences, UiTheme, WorkspaceRecord, @@ -102,20 +102,7 @@ export function WorkspacePanel({ - - - {t("Changes only what you see; agents keep the company's working language.")} - +
diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 6823dcbc..19c4238a 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -120,6 +120,10 @@ export type ModelCheckResult = { passed: true } | { passed: false; detail: strin export interface DesktopApi { loadSnapshot(): Promise>; + /** Restarts the app, which a display-language change needs; the user asks for it after seeing what it interrupts. */ + relaunchApp(): Promise>; + /** Every agent session running now, in every company: what a restart interrupts. */ + runningSessionCount(): Promise>; bootstrapOnboarding(input: BootstrapOnboardingInput): Promise>; setActiveOrganization(input: SetActiveOrganizationInput): Promise>; setUiPreferences(input: SetUiPreferencesInput): Promise>; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 400b6772..e434758b 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,5 +1,7 @@ export const IPC_CHANNELS = { loadSnapshot: "agentcompany:load-snapshot", + relaunchApp: "agentcompany:relaunch-app", + runningSessionCount: "agentcompany:running-session-count", bootstrapOnboarding: "agentcompany:bootstrap-onboarding", setActiveOrganization: "agentcompany:set-active-organization", setUiPreferences: "agentcompany:set-ui-preferences", diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index 6633d4dd..9fa26ae7 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -76,6 +76,15 @@ test("the pinned CEO conversation is the Messages front door", async () => { await page.getByRole("button", { name: "Settings" }).click(); await expect(page.getByRole("note").filter({ hasText: "Applies to FlowPilot only." })).toBeVisible(); await expect(page.getByRole("note").filter({ hasText: "Applies to every company in this app." })).toBeVisible(); + // Picking a language does not restart the app; the restart waits for the user, after saying what it interrupts. + await page.getByLabel("Language", { exact: true }).selectOption("zh"); + await expect(page.getByRole("status").filter({ hasText: "中文 applies after a restart." })).toBeVisible(); + await page.getByRole("button", { name: "Restart now" }).click(); + await expect(page.locator(".modal")).toContainText("Text you typed but have not sent or saved is lost."); + await expect(page.locator(".modal")).toContainText("Running agent sessions: 0. They stop now"); + await page.locator(".modal").getByRole("button", { name: "Cancel" }).click(); + await page.getByLabel("Language", { exact: true }).selectOption("en"); + await expect(page.getByRole("status").filter({ hasText: "applies after a restart." })).toHaveCount(0); // How the company runs can be changed after onboarding. const editCompany = async (mode: string, autoHires: boolean) => { await page.getByRole("button", { name: "Edit company" }).click();