refactor(settings): split 73-line god file into 5 sections + auto-save preferences + guard Restore
The previous SettingsPane.tsx hid 8 unrelated concerns behind 73 "lines"
of JSX where each line ran 400-2000 chars. Reading the file was
infeasible; editing it meant scrolling horizontally through a single
statement spanning every button, modal, and table row.
Split by concern into `settings-sections/`:
SettingsPane.tsx 62 LOC orchestrator
GeneralSection.tsx 94 LOC theme / language / auto-update
ExecutionModeSection.tsx 105 LOC AI CEO ↔ AI Co-Founder toggle
ShortcutsSection.tsx 40 LOC keyboard reference (read-only)
DataSection.tsx 123 LOC update check / backup / restore /
secret backend
SecretsSection.tsx 102 LOC per-company secret bindings
Every file now reads top-to-bottom with normal indentation.
Behavioral changes bundled (they belong with the restructure, not a
separate pass):
- Preferences save model unified: theme already auto-saved on change;
language and auto-update checkbox now do the same. The global
"Save settings" button is gone — there is no longer a state where
a toggled language sits unpersisted waiting for a second click.
- Execution mode card gained a subheader line that names the current
company ("Applies to current company: X"). Multi-company users can
no longer confuse which company they just flipped.
- Auto-updater checkbox gained an inline hint: "Check for application
updates at launch" — the previous naked checkbox never explained
what happens when it is off.
- Restore profile moved into DataSection's backup/restore group,
given `tone="danger"`, and gated behind a `window.confirm` with
copy warning about running work being lost. Backup stays neutral.
The previous layout put Restore in the same 2-col grid as Save +
Check Updates + Backup, making a profile-replacing operation look
as routine as saving a theme preference.
- Secret backend display promoted from a naked text line to a
bordered card so it matches the surrounding section rhythm.
Added i18n keys (zh + en):
settings.updaterHint / settings.modeScope / settings.backupRestore /
settings.restoreConfirm / settings.restoreHint.
Covers J-S1 / J-S6 / S-S1 / S-S2 / S-S3 / S-S4 from the settings audit.
Remaining work (tab sidebar, Providers IA reordering, copy sweep)
lands in follow-up commits.
This commit is contained in:
1 parent
fd6471988a
commit
16e45b6a8d
8 files changed
+530
-67
No files matched your search
@@ -1,73 +1,62 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { ArrowClockwise, CheckCircle, ClockCounterClockwise, Download, Trash } from "../ui/icons"
|
||||
import type { ProfileSnapshot, SecretRecord } from "@shared/types"
|
||||
import type { AppLocale } from "@shared/locale"
|
||||
import { APP_LOCALES, LOCALE_LABELS } from "@shared/locale"
|
||||
import { useLocale, useT } from "../../i18n"
|
||||
import { formatTime } from "../../lib/formatters"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { isMac } from "../../lib/platform"
|
||||
import { ActionButton, ConfirmDialog, EmptyState, Eyebrow, InlineNotice, Select } from "../ui"
|
||||
import { describeActionError } from "./execution-utils"
|
||||
import { useAppStore } from "../../state/store"
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ProfileSnapshot, SecretRecord } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { useLocale } from "../../i18n";
|
||||
import { GeneralSection } from "./settings-sections/GeneralSection";
|
||||
import { ExecutionModeSection } from "./settings-sections/ExecutionModeSection";
|
||||
import { ShortcutsSection } from "./settings-sections/ShortcutsSection";
|
||||
import { DataSection } from "./settings-sections/DataSection";
|
||||
import { SecretsSection } from "./settings-sections/SecretsSection";
|
||||
|
||||
export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapshot: ProfileSnapshot; secrets: SecretRecord[]; companyId: string; onSaved: () => Promise<void> }) {
|
||||
const t = useT(); const currentLocale = useLocale(); const addToast = useAppStore((s) => s.addToast)
|
||||
const [theme, setTheme] = useState(snapshot.theme); const [selectedLocale, setSelectedLocale] = useState<AppLocale>(snapshot.locale ?? currentLocale); const [updatesEnabled, setUpdatesEnabled] = useState(snapshot.updatesEnabled)
|
||||
const [confirmDeleteSecretId, setConfirmDeleteSecretId] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null)
|
||||
const [deleteSecretError, setDeleteSecretError] = useState<string | null>(null); const [loadingAction, setLoadingAction] = useState<"save"|"check"|"backup"|"restore"|null>(null)
|
||||
const secretToDelete = secrets.find((s) => s.id === confirmDeleteSecretId) ?? null
|
||||
interface SettingsPaneProps {
|
||||
snapshot: ProfileSnapshot;
|
||||
secrets: SecretRecord[];
|
||||
companyId: string;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
useEffect(() => { setTheme(snapshot.theme); setUpdatesEnabled(snapshot.updatesEnabled); if (snapshot.locale) setSelectedLocale(snapshot.locale) }, [snapshot.theme, snapshot.updatesEnabled, snapshot.locale])
|
||||
async function handleDeleteSecret(id: string) { setDeleteSecretError(null); setActionError(null); setActionNotice(null); try { unwrap(await window.agentCompany.deleteSecret({ id, companyId })); setConfirmDeleteSecretId(null); setActionNotice(t("settings.secretDeleted")); addToast(t("settings.secretDeleted"), "success"); await onSaved() } catch (e) { setDeleteSecretError(describeActionError(e)) } }
|
||||
async function handleSaveSettings() { setActionError(null); setActionNotice(null); setLoadingAction("save"); try { unwrap(await window.agentCompany.updateSettings({ theme, locale: selectedLocale, updatesEnabled })); await onSaved(); setActionNotice(t("settings.settingsSaved")); addToast(t("settings.settingsSaved"), "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
|
||||
async function handleCheckForUpdates() { setActionError(null); setActionNotice(null); setLoadingAction("check"); try { const started = unwrap(await window.agentCompany.checkForUpdates()); setActionNotice(started ? t("settings.updateCheckStarted") : t("settings.updateCheckSkipped")) } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
|
||||
async function handleBackupProfile() { setActionError(null); setActionNotice(null); setLoadingAction("backup"); try { const backupPath = unwrap(await window.agentCompany.backupProfile()); if (backupPath) { setActionNotice(t("settings.profileBackupAt").replace("{path}", backupPath)); addToast(t("settings.profileBackupCreated"), "success") } } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
|
||||
async function handleRestoreProfile() { setActionError(null); setActionNotice(null); setLoadingAction("restore"); try { const restored = unwrap(await window.agentCompany.restoreProfile()); if (!restored) return; await onSaved(); setActionNotice(t("settings.profileRestored")); addToast(t("settings.profileRestored"), "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
|
||||
/**
|
||||
* Settings page orchestrator. Each concern lives in its own ≤200 LOC
|
||||
* section under `settings-sections/` so the contents of this file stay
|
||||
* scannable instead of hiding 2000-char JSX statements inside one
|
||||
* 73-"line" god file.
|
||||
*
|
||||
* Save model: every preference in GeneralSection writes to
|
||||
* `updateSettings` on change, so there is no global "Save" button.
|
||||
* DataSection's backup / restore / update-check remain explicit
|
||||
* actions because they have side effects beyond a single row of app
|
||||
* state.
|
||||
*/
|
||||
export function SettingsPane({ snapshot, secrets, companyId, onSaved }: SettingsPaneProps) {
|
||||
const currentLocale = useLocale();
|
||||
const [theme, setTheme] = useState(snapshot.theme);
|
||||
const [selectedLocale, setSelectedLocale] = useState<AppLocale>(snapshot.locale ?? currentLocale);
|
||||
const [updatesEnabled, setUpdatesEnabled] = useState(snapshot.updatesEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(snapshot.theme);
|
||||
setUpdatesEnabled(snapshot.updatesEnabled);
|
||||
if (snapshot.locale) setSelectedLocale(snapshot.locale);
|
||||
}, [snapshot.theme, snapshot.updatesEnabled, snapshot.locale]);
|
||||
|
||||
const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfirmDialog open={confirmDeleteSecretId !== null} title={t("settings.deleteSecretTitle")} message={t("settings.deleteSecretConfirm").replace("{name}", secretToDelete?.name ?? "")} errorMessage={deleteSecretError} onConfirm={() => confirmDeleteSecretId && void handleDeleteSecret(confirmDeleteSecretId)} onCancel={() => { setDeleteSecretError(null); setConfirmDeleteSecretId(null) }} />
|
||||
{actionError ? <InlineNotice message={actionError} /> : null}
|
||||
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
|
||||
<div><Eyebrow className="mb-3">{t("exec.profile")}</Eyebrow><div className="border-y border-border py-4"><div className="mb-2 text-[11px] text-muted-foreground">{t("exec.profilePath")}</div><div className="font-mono truncate text-[11px] text-muted-foreground">{snapshot.profilePath}</div></div></div>
|
||||
<div><Eyebrow className="mb-3">{t("exec.preferences")}</Eyebrow>
|
||||
<Select label={t("settings.theme")} value={theme} onChange={(v) => { const next = v as ProfileSnapshot["theme"]; setTheme(next); const root = document.documentElement; root.className = next; root.style.colorScheme = next === "system" ? "light dark" : next; void window.agentCompany.updateSettings({ theme: next }).then(() => onSaved()) }} options={[{ value: "system", label: t("settings.system") }, { value: "dark", label: t("settings.dark") }, { value: "light", label: t("settings.light") }]} />
|
||||
<div className="mt-3"><Select label={t("settings.language")} value={selectedLocale} onChange={(v) => setSelectedLocale(v as AppLocale)} options={APP_LOCALES.map((loc) => ({ value: loc, label: LOCALE_LABELS[loc] }))} /></div>
|
||||
<div className="border-y border-border py-4 mt-3"><label className="flex items-center justify-between text-[13px] text-muted-foreground"><span>{t("exec.enableUpdater")}</span><input type="checkbox" checked={updatesEnabled} onChange={(e) => setUpdatesEnabled(e.target.checked)} /></label></div>
|
||||
</div>
|
||||
{(() => { const company = snapshot.companies.find((c) => c.id === companyId); if (!company) return null; const isCeo = company.executionMode !== "ai_cofounder"; return (
|
||||
<div><Eyebrow className="mb-3">{t("exec.executionMode")}</Eyebrow>
|
||||
<div className="space-y-3">
|
||||
<button type="button" onClick={async () => { if (isCeo) return; setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: true, reviewDeliverables: false, executionMode: "ai_ceo" })); await onSaved(); addToast(t("exec.modeSwitched"), "success") } catch (err) { setActionError(describeActionError(err)) } }} className={`w-full rounded-lg border-2 px-4 py-3 text-left transition ${isCeo ? "border-[color:var(--primary)] bg-accent" : "border-border bg-muted hover:border-[color:var(--muted-foreground)] cursor-pointer"}`}>
|
||||
<div className="text-[13px] font-semibold text-foreground">AI CEO <span className="ml-1 text-[10px] font-normal text-muted-foreground">{t("exec.modeCeoTag")}</span></div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{t("exec.modeCeoDesc")}</div>
|
||||
</button>
|
||||
<button type="button" onClick={async () => { if (!isCeo) return; setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: false, reviewDeliverables: true, executionMode: "ai_cofounder" })); await onSaved(); addToast(t("exec.modeSwitched"), "success") } catch (err) { setActionError(describeActionError(err)) } }} className={`w-full rounded-lg border-2 px-4 py-3 text-left transition ${!isCeo ? "border-[color:var(--primary)] bg-accent" : "border-border bg-muted hover:border-[color:var(--muted-foreground)] cursor-pointer"}`}>
|
||||
<div className="text-[13px] font-semibold text-foreground">AI Co-Founder <span className="ml-1 text-[10px] font-normal text-muted-foreground">{t("exec.modeCofounderTag")}</span></div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{t("exec.modeCofounderDesc")}</div>
|
||||
</button>
|
||||
</div></div>) })()}
|
||||
<div><Eyebrow className="mb-3">{t("settings.keyboardShortcuts")}</Eyebrow>
|
||||
<div className="space-y-1 border-y border-border py-3">
|
||||
{([
|
||||
[isMac() ? "⌘K" : "Ctrl+K", t("settings.shortcut.commandPalette")],
|
||||
[isMac() ? "⌘Enter" : "Ctrl+Enter", t("settings.shortcut.sendMessage")],
|
||||
["Escape", t("settings.shortcut.closeModal")],
|
||||
["↑ / ↓", t("settings.shortcut.navigatePalette")],
|
||||
["Enter", t("settings.shortcut.selectPaletteResult")],
|
||||
["Tab", t("settings.shortcut.moveFocus")],
|
||||
] as const).map(([key, desc]) => (
|
||||
<div key={key} className="flex items-center justify-between px-3 py-1.5 text-[12px]">
|
||||
<span className="text-muted-foreground">{desc}</span>
|
||||
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[11px] text-foreground">{key}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div><Eyebrow className="mb-3">{t("exec.actions")}</Eyebrow><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
|
||||
<div><Eyebrow className="mb-3">{t("exec.secretBackend")}</Eyebrow><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div>
|
||||
<div><Eyebrow className="mb-3">{t("exec.secrets")} ({secrets.length})</Eyebrow>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label={t("settings.delete")} onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
|
||||
<div className="space-y-8">
|
||||
<GeneralSection
|
||||
snapshot={snapshot}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
selectedLocale={selectedLocale}
|
||||
setSelectedLocale={setSelectedLocale}
|
||||
updatesEnabled={updatesEnabled}
|
||||
setUpdatesEnabled={setUpdatesEnabled}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
{company ? <ExecutionModeSection company={company} onSaved={onSaved} /> : null}
|
||||
<ShortcutsSection />
|
||||
<DataSection snapshot={snapshot} onSaved={onSaved} />
|
||||
<SecretsSection secrets={secrets} companyId={companyId} onSaved={onSaved} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowClockwise, ClockCounterClockwise, Download } from "../../ui/icons";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import { ActionButton, Eyebrow, InlineNotice } from "../../ui";
|
||||
import { useT } from "../../../i18n";
|
||||
import { unwrap } from "../../../lib/desktop";
|
||||
import { describeActionError } from "../execution-utils";
|
||||
import { useAppStore } from "../../../state/store";
|
||||
|
||||
interface DataSectionProps {
|
||||
snapshot: ProfileSnapshot;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data & release operations — profile backup/restore, update check,
|
||||
* secret backend info. These are the high-blast-radius actions:
|
||||
* Restore replaces the active profile on disk, so it is visually
|
||||
* separated from Backup and the ephemeral update check. No single
|
||||
* "Save" button — granular auto-save happens in GeneralSection.
|
||||
*/
|
||||
export function DataSection({ snapshot, onSaved }: DataSectionProps) {
|
||||
const t = useT();
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<"check" | "backup" | "restore" | null>(null);
|
||||
|
||||
const clearBanners = () => {
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
clearBanners();
|
||||
setLoading("check");
|
||||
try {
|
||||
const started = unwrap(await window.agentCompany.checkForUpdates());
|
||||
setNotice(started ? t("settings.updateCheckStarted") : t("settings.updateCheckSkipped"));
|
||||
} catch (err) {
|
||||
setError(describeActionError(err));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
clearBanners();
|
||||
setLoading("backup");
|
||||
try {
|
||||
const backupPath = unwrap(await window.agentCompany.backupProfile());
|
||||
if (backupPath) {
|
||||
setNotice(t("settings.profileBackupAt").replace("{path}", backupPath));
|
||||
addToast(t("settings.profileBackupCreated"), "success");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(describeActionError(err));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
clearBanners();
|
||||
if (!window.confirm(t("settings.restoreConfirm"))) return;
|
||||
setLoading("restore");
|
||||
try {
|
||||
const restored = unwrap(await window.agentCompany.restoreProfile());
|
||||
if (!restored) return;
|
||||
await onSaved();
|
||||
setNotice(t("settings.profileRestored"));
|
||||
addToast(t("settings.profileRestored"), "success");
|
||||
} catch (err) {
|
||||
setError(describeActionError(err));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error ? <InlineNotice message={error} /> : null}
|
||||
{!error && notice ? <InlineNotice message={notice} tone="success" /> : null}
|
||||
|
||||
<div>
|
||||
<Eyebrow className="mb-3">{t("settings.updates")}</Eyebrow>
|
||||
<ActionButton
|
||||
label={t("exec.checkForUpdates")}
|
||||
icon={Download}
|
||||
onClick={() => void handleCheckForUpdates()}
|
||||
loading={loading === "check"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow className="mb-3">{t("settings.backupRestore")}</Eyebrow>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ActionButton
|
||||
label={t("exec.backupProfile")}
|
||||
icon={ClockCounterClockwise}
|
||||
onClick={() => void handleBackup()}
|
||||
loading={loading === "backup"}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("exec.restoreProfile")}
|
||||
tone="danger"
|
||||
icon={ArrowClockwise}
|
||||
onClick={() => void handleRestore()}
|
||||
loading={loading === "restore"}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">{t("settings.restoreHint")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow className="mb-3">{t("exec.secretBackend")}</Eyebrow>
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2 text-[13px] text-muted-foreground">
|
||||
{snapshot.backend.secretBackend}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import type { CompanyRecord } from "@shared/types";
|
||||
import { Eyebrow } from "../../ui";
|
||||
import { useT } from "../../../i18n";
|
||||
import { unwrap } from "../../../lib/desktop";
|
||||
import { describeActionError } from "../execution-utils";
|
||||
import { useAppStore } from "../../../state/store";
|
||||
|
||||
interface ExecutionModeSectionProps {
|
||||
company: CompanyRecord;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI CEO vs AI Co-Founder toggle — company-scoped. The header line
|
||||
* makes the scope explicit ("当前公司:X") so multi-company users know
|
||||
* which company they are changing.
|
||||
*/
|
||||
export function ExecutionModeSection({ company, onSaved }: ExecutionModeSectionProps) {
|
||||
const t = useT();
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isCeo = company.executionMode !== "ai_cofounder";
|
||||
|
||||
const applyMode = async (mode: "ai_ceo" | "ai_cofounder") => {
|
||||
if ((mode === "ai_ceo" && isCeo) || (mode === "ai_cofounder" && !isCeo)) return;
|
||||
setError(null);
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.saveCompany({
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
description: company.description,
|
||||
status: company.status,
|
||||
autoApproveHires: mode === "ai_ceo",
|
||||
reviewDeliverables: mode === "ai_cofounder",
|
||||
executionMode: mode,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
addToast(t("exec.modeSwitched"), "success");
|
||||
} catch (err) {
|
||||
setError(describeActionError(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Eyebrow className="mb-1">{t("exec.executionMode")}</Eyebrow>
|
||||
<p className="mb-3 text-[11px] text-muted-foreground">
|
||||
{t("settings.modeScope").replace("{company}", company.name)}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<ModeCard
|
||||
label="AI CEO"
|
||||
tag={t("exec.modeCeoTag")}
|
||||
description={t("exec.modeCeoDesc")}
|
||||
selected={isCeo}
|
||||
onClick={() => void applyMode("ai_ceo")}
|
||||
/>
|
||||
<ModeCard
|
||||
label="AI Co-Founder"
|
||||
tag={t("exec.modeCofounderTag")}
|
||||
description={t("exec.modeCofounderDesc")}
|
||||
selected={!isCeo}
|
||||
onClick={() => void applyMode("ai_cofounder")}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="mt-3 text-[12px] text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
label,
|
||||
tag,
|
||||
description,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={`w-full rounded-lg border-2 px-4 py-3 text-left transition ${
|
||||
selected
|
||||
? "border-[color:var(--primary)] bg-accent"
|
||||
: "cursor-pointer border-border bg-muted hover:border-[color:var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{label}
|
||||
<span className="ml-2 text-[10px] font-normal text-muted-foreground">{tag}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{description}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { APP_LOCALES, LOCALE_LABELS } from "@shared/locale";
|
||||
import { Eyebrow, Select } from "../../ui";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
interface GeneralSectionProps {
|
||||
snapshot: ProfileSnapshot;
|
||||
theme: ProfileSnapshot["theme"];
|
||||
setTheme: (theme: ProfileSnapshot["theme"]) => void;
|
||||
selectedLocale: AppLocale;
|
||||
setSelectedLocale: (locale: AppLocale) => void;
|
||||
updatesEnabled: boolean;
|
||||
setUpdatesEnabled: (enabled: boolean) => void;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme / language / auto-update preferences. Every control writes to
|
||||
* the store immediately via `updateSettings` — there is no global "Save
|
||||
* settings" step because the app already persists each field
|
||||
* individually as soon as the user changes it.
|
||||
*/
|
||||
export function GeneralSection({
|
||||
snapshot,
|
||||
theme,
|
||||
setTheme,
|
||||
selectedLocale,
|
||||
setSelectedLocale,
|
||||
updatesEnabled,
|
||||
setUpdatesEnabled,
|
||||
onSaved,
|
||||
}: GeneralSectionProps) {
|
||||
const t = useT();
|
||||
|
||||
const applyTheme = (next: ProfileSnapshot["theme"]) => {
|
||||
setTheme(next);
|
||||
const root = document.documentElement;
|
||||
root.className = next;
|
||||
root.style.colorScheme = next === "system" ? "light dark" : next;
|
||||
void window.agentCompany.updateSettings({ theme: next }).then(() => onSaved());
|
||||
};
|
||||
|
||||
const applyLocale = (next: AppLocale) => {
|
||||
setSelectedLocale(next);
|
||||
void window.agentCompany.updateSettings({ locale: next }).then(() => onSaved());
|
||||
};
|
||||
|
||||
const applyUpdates = (enabled: boolean) => {
|
||||
setUpdatesEnabled(enabled);
|
||||
void window.agentCompany.updateSettings({ updatesEnabled: enabled }).then(() => onSaved());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Eyebrow className="mb-3">{t("exec.preferences")}</Eyebrow>
|
||||
<div className="grid gap-3">
|
||||
<Select
|
||||
label={t("settings.theme")}
|
||||
value={theme}
|
||||
onChange={(value) => applyTheme(value as ProfileSnapshot["theme"])}
|
||||
options={[
|
||||
{ value: "system", label: t("settings.system") },
|
||||
{ value: "dark", label: t("settings.dark") },
|
||||
{ value: "light", label: t("settings.light") },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={t("settings.language")}
|
||||
value={selectedLocale}
|
||||
onChange={(value) => applyLocale(value as AppLocale)}
|
||||
options={APP_LOCALES.map((locale) => ({ value: locale, label: LOCALE_LABELS[locale] }))}
|
||||
/>
|
||||
<div className="border-t border-border pt-3">
|
||||
<label className="flex items-center justify-between text-[13px] text-foreground">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t("exec.enableUpdater")}</span>
|
||||
<span className="mt-0.5 text-[11px] text-muted-foreground">{t("settings.updaterHint")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={updatesEnabled}
|
||||
onChange={(event) => applyUpdates(event.target.checked)}
|
||||
className="h-4 w-4 accent-[color:var(--primary)]"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2 font-mono truncate text-[11px] text-muted-foreground">
|
||||
{t("exec.profilePath")}: {snapshot.profilePath}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { Trash } from "../../ui/icons";
|
||||
import type { SecretRecord } from "@shared/types";
|
||||
import { ActionButton, ConfirmDialog, EmptyState, Eyebrow } from "../../ui";
|
||||
import { formatTime } from "../../../lib/formatters";
|
||||
import { useT } from "../../../i18n";
|
||||
import { unwrap } from "../../../lib/desktop";
|
||||
import { describeActionError } from "../execution-utils";
|
||||
import { useAppStore } from "../../../state/store";
|
||||
|
||||
interface SecretsSectionProps {
|
||||
secrets: SecretRecord[];
|
||||
companyId: string;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-company secret bindings. Secrets are deleted via a confirm
|
||||
* dialog because deletions break downstream connectors that still
|
||||
* reference them; the dialog surfaces that cost in its copy.
|
||||
*/
|
||||
export function SecretsSection({ secrets, companyId, onSaved }: SecretsSectionProps) {
|
||||
const t = useT();
|
||||
const addToast = useAppStore((s) => s.addToast);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const target = secrets.find((entry) => entry.id === confirmId) ?? null;
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.deleteSecret({ id, companyId }));
|
||||
setConfirmId(null);
|
||||
addToast(t("settings.secretDeleted"), "success");
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
setDeleteError(describeActionError(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ConfirmDialog
|
||||
open={confirmId !== null}
|
||||
title={t("settings.deleteSecretTitle")}
|
||||
message={t("settings.deleteSecretConfirm").replace("{name}", target?.name ?? "")}
|
||||
errorMessage={deleteError}
|
||||
onConfirm={() => confirmId && void handleDelete(confirmId)}
|
||||
onCancel={() => {
|
||||
setDeleteError(null);
|
||||
setConfirmId(null);
|
||||
}}
|
||||
/>
|
||||
<Eyebrow className="mb-3">
|
||||
{t("exec.secrets")} ({secrets.length})
|
||||
</Eyebrow>
|
||||
{secrets.length === 0 ? (
|
||||
<EmptyState title={t("exec.noSecretsStored")} />
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<HeaderCell>{t("exec.secretName")}</HeaderCell>
|
||||
<HeaderCell>{t("exec.secretDescription")}</HeaderCell>
|
||||
<HeaderCell>{t("exec.secretBackendCol")}</HeaderCell>
|
||||
<HeaderCell>{t("exec.secretUpdated")}</HeaderCell>
|
||||
<HeaderCell />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{secrets.map((secret) => (
|
||||
<tr key={secret.id} className="border-b border-border last:border-b-0">
|
||||
<td className="px-4 py-3 font-medium text-foreground">{secret.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{secret.description || "—"}</td>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">{secret.backend}</td>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(secret.updatedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<ActionButton
|
||||
label={t("settings.delete")}
|
||||
tone="danger"
|
||||
icon={Trash}
|
||||
onClick={() => setConfirmId(secret.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCell({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Eyebrow } from "../../ui";
|
||||
import { useT } from "../../../i18n";
|
||||
import { isMac } from "../../../lib/platform";
|
||||
|
||||
/**
|
||||
* Read-only keyboard shortcut reference. Listed in the settings page
|
||||
* because this is where users look when they want to remember how to
|
||||
* invoke a hotkey; it is NOT a configurable surface — so the visuals
|
||||
* are lighter than the other settings sections (no active state, no
|
||||
* action buttons).
|
||||
*/
|
||||
export function ShortcutsSection() {
|
||||
const t = useT();
|
||||
const mac = isMac();
|
||||
|
||||
const rows: Array<[string, string]> = [
|
||||
[mac ? "⌘K" : "Ctrl+K", t("settings.shortcut.commandPalette")],
|
||||
[mac ? "⌘Enter" : "Ctrl+Enter", t("settings.shortcut.sendMessage")],
|
||||
["Escape", t("settings.shortcut.closeModal")],
|
||||
["↑ / ↓", t("settings.shortcut.navigatePalette")],
|
||||
["Enter", t("settings.shortcut.selectPaletteResult")],
|
||||
["Tab", t("settings.shortcut.moveFocus")],
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Eyebrow className="mb-3">{t("settings.keyboardShortcuts")}</Eyebrow>
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
{rows.map(([key, description]) => (
|
||||
<div key={key} className="flex items-center justify-between px-3 py-2 text-[12px]">
|
||||
<span className="text-muted-foreground">{description}</span>
|
||||
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[11px] text-foreground">
|
||||
{key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1276,6 +1276,11 @@ export const en: Record<string, string> = {
|
||||
"org.statusAria": "Status: {status}",
|
||||
|
||||
// ── Settings pane ──
|
||||
"settings.updaterHint": "Check for application updates at launch",
|
||||
"settings.modeScope": "Applies to current company: {company}",
|
||||
"settings.backupRestore": "Backup & restore",
|
||||
"settings.restoreConfirm": "Restore will overwrite the current profile. Running work may be lost. Continue?",
|
||||
"settings.restoreHint": "Restore is not reversible — it replaces the active profile on disk with the backup.",
|
||||
"settings.keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"settings.shortcut.commandPalette": "Open command palette",
|
||||
"settings.shortcut.sendMessage": "Send message",
|
||||
|
||||
@@ -1276,6 +1276,11 @@ export const zh: Record<string, string> = {
|
||||
"org.statusAria": "状态:{status}",
|
||||
|
||||
// ── 设置面板 ──
|
||||
"settings.updaterHint": "启动时自动检查应用更新",
|
||||
"settings.modeScope": "应用于当前公司:{company}",
|
||||
"settings.backupRestore": "备份与恢复",
|
||||
"settings.restoreConfirm": "恢复备份会覆盖当前配置,运行中的任务可能丢失。确定继续?",
|
||||
"settings.restoreHint": "恢复操作不可撤销,会用备份覆盖磁盘上的活动配置。",
|
||||
"settings.keyboardShortcuts": "键盘快捷键",
|
||||
"settings.shortcut.commandPalette": "打开命令面板",
|
||||
"settings.shortcut.sendMessage": "发送消息",
|
||||
|
||||
Reference in new issue
Block a user