feat(settings): add top-row tab navigation; fold Providers + mode + secrets into one IA

The settings page was a single vertical scroll containing seven
unrelated concerns. Users had to scroll past theme preferences to
reach Providers — which is the most-touched surface — and mode +
secrets + shortcuts lived below that. There was no signal about
where anything was.

Introduced `SettingsShell` with a horizontal tab row along the top:

  General | Execution mode | Providers | Shortcuts | Data | Secrets

Each tab swaps the content pane. Active tab is visually anchored
with a 2px primary underline + foreground text; inactive tabs stay
muted with a hover promotion. All tabs are real `role="tab"` +
`aria-selected` so screen readers announce the current pane; the
nav has `aria-label="Settings categories"`.

Tab state is local — settings are ephemeral navigation, no reason
to persist across company switches or app restarts. Default is
"general" because that's where first-time users land.

Refactor consequences:

- `OperationsSections.tsx` now renders `<SettingsShell />` + the
  OAuth prompt modal portal; the old double-Panel wrapper with
  "Profile and release" + "Providers" titles is gone. The "Profile
  and release" label always fought with what was actually inside
  the pane — Jobs audit item S-S2 addressed.
- Providers moved from a separate sibling Panel into the main IA.
  Previously Providers came AFTER a 7-section scroll; now it is a
  peer of General / Mode / etc., reachable in one click.
- Deleted the now-unused `SettingsPane.tsx` orchestrator. Its
  single-column rendering survived only as a composition step that
  SettingsShell replaces.

Added i18n keys (zh + en):
  settings.tabs.general / mode / providers / shortcuts / data /
  secrets; settings.tabsAria.

Covers J-S2 / J-S7 / S-S5 / S-S7 from the settings audit.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 21:07:52 -04:00
1 parent 16e45b6a8d
commit 03bcc8a8f0
5 files changed
+144 -73

No files matched your search

@@ -1,62 +0,0 @@
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";
interface SettingsPaneProps {
snapshot: ProfileSnapshot;
secrets: SecretRecord[];
companyId: string;
onSaved: () => Promise<void>;
}
/**
* 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-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,121 @@
import { useState, useMemo } from "react";
import type { ProfileSnapshot, SecretRecord } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { useEffect } from "react";
import { useLocale, useT } 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";
import { ProvidersPanel } from "../settings/providers/ProvidersPanel";
type SettingsTab = "general" | "mode" | "providers" | "shortcuts" | "data" | "secrets";
interface SettingsShellProps {
snapshot: ProfileSnapshot;
secrets: SecretRecord[];
companyId: string;
onSaved: () => Promise<void>;
}
/**
* Settings page with top-row tab navigation. Replaces the previous
* single-scroll layout that stacked seven unrelated concerns
* (preferences, execution mode, providers, keyboard reference, data
* ops, secrets) in one column forcing users to scroll past everything
* they didn't need.
*
* Tabs live as local state — settings are transient; there is no
* benefit to persisting the active tab across company switches or
* app restarts. Default is "general" because first-time users most
* often land here for theme / language.
*/
export function SettingsShell({ snapshot, secrets, companyId, onSaved }: SettingsShellProps) {
const t = useT();
const currentLocale = useLocale();
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
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 = useMemo(
() => snapshot.companies.find((entry) => entry.id === companyId) ?? null,
[snapshot.companies, companyId],
);
const tabs: Array<{ id: SettingsTab; label: string }> = [
{ id: "general", label: t("settings.tabs.general") },
{ id: "mode", label: t("settings.tabs.mode") },
{ id: "providers", label: t("settings.tabs.providers") },
{ id: "shortcuts", label: t("settings.tabs.shortcuts") },
{ id: "data", label: t("settings.tabs.data") },
{ id: "secrets", label: t("settings.tabs.secrets") },
];
return (
<div className="space-y-6">
<nav
role="tablist"
aria-label={t("settings.tabsAria")}
className="flex flex-wrap gap-1 border-b border-border"
>
{tabs.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveTab(tab.id)}
className={`relative px-4 py-2 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
{active ? (
<span
aria-hidden="true"
className="absolute inset-x-2 bottom-[-1px] h-[2px] bg-primary"
/>
) : null}
</button>
);
})}
</nav>
<div>
{activeTab === "general" ? (
<GeneralSection
snapshot={snapshot}
theme={theme}
setTheme={setTheme}
selectedLocale={selectedLocale}
setSelectedLocale={setSelectedLocale}
updatesEnabled={updatesEnabled}
setUpdatesEnabled={setUpdatesEnabled}
onSaved={onSaved}
/>
) : null}
{activeTab === "mode" && company ? (
<ExecutionModeSection company={company} onSaved={onSaved} />
) : null}
{activeTab === "providers" ? <ProvidersPanel /> : null}
{activeTab === "shortcuts" ? <ShortcutsSection /> : null}
{activeTab === "data" ? <DataSection snapshot={snapshot} onSaved={onSaved} /> : null}
{activeTab === "secrets" ? (
<SecretsSection secrets={secrets} companyId={companyId} onSaved={onSaved} />
) : null}
</div>
</div>
);
}