diff --git a/.changeset/fn-6771-localize-settings-sections.md b/.changeset/fn-6771-localize-settings-sections.md new file mode 100644 index 0000000000..bcad0cf642 --- /dev/null +++ b/.changeset/fn-6771-localize-settings-sections.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Keep settings section dashboard copy covered by i18n lint by removing the settings/sections deferral and regenerating i18n resource types. diff --git a/docs/i18n-contributing.md b/docs/i18n-contributing.md index e19544f56a..4a58e114d2 100644 --- a/docs/i18n-contributing.md +++ b/docs/i18n-contributing.md @@ -52,7 +52,8 @@ content in ``, instead of hiding source directories. Any remaining user-facing copy must be localized with `t()` / `` and an `en` catalog entry. A temporary deferral is only acceptable when it is scoped to specific files or a small cluster in `lint.ignore`, includes an `FNXC` rationale, -and has a filed follow-up task that removes the ignore. +and has a filed follow-up task that removes the ignore. The settings sections +cluster is no longer deferred as of FN-6771; keep those files covered by lint. ## Translating an existing language diff --git a/i18next.config.ts b/i18next.config.ts index a16ffa22c9..adce4b12ab 100644 --- a/i18next.config.ts +++ b/i18next.config.ts @@ -8,8 +8,9 @@ import { const DEFERRED_I18N_LINT_FILES = [ // FNXC:i18n-LintBaseline 2026-06-19-00:00: // These exact files still carry pre-existing user-facing copy debt after FN-6749 restored the guardrail scope and token suppression. - // Deferral split after FN-6769: 23 settings section files -> FN-6771 and workflow/task/setup/PR files -> FN-6770. // Keep the deferral file-scoped and remove entries as those follow-ups localize each cluster. + // FNXC:i18n-LintBaseline 2026-06-20-00:00: + // FN-6771 localized the settings/sections cluster, so those files are no longer deferred and must stay covered by i18n lint. "packages/dashboard/app/components/WorkflowSelector.tsx", "packages/dashboard/app/components/WorkflowResultsTab.tsx", "packages/dashboard/app/components/WorkflowNodeEditor.tsx", @@ -22,29 +23,6 @@ const DEFERRED_I18N_LINT_FILES = [ "packages/dashboard/app/components/PrPanel.tsx", "packages/dashboard/app/components/PrCreateModal.tsx", "packages/dashboard/app/components/Board.tsx", - "packages/dashboard/app/components/settings/sections/WorktreesSection.tsx", - "packages/dashboard/app/components/settings/sections/SchedulingSection.tsx", - "packages/dashboard/app/components/settings/sections/ScheduledEvalsSection.tsx", - "packages/dashboard/app/components/settings/sections/RuntimesSections.tsx", - "packages/dashboard/app/components/settings/sections/ResearchProjectSection.tsx", - "packages/dashboard/app/components/settings/sections/ResearchGlobalSection.tsx", - "packages/dashboard/app/components/settings/sections/RemoteSection.tsx", - "packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx", - "packages/dashboard/app/components/settings/sections/PluginsSection.tsx", - "packages/dashboard/app/components/settings/sections/NotificationsSection.tsx", - "packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx", - "packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx", - "packages/dashboard/app/components/settings/sections/MergeSection.tsx", - "packages/dashboard/app/components/settings/sections/MemorySection.tsx", - "packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx", - "packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx", - "packages/dashboard/app/components/settings/sections/GeneralSection.tsx", - "packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx", - "packages/dashboard/app/components/settings/sections/CommandsSection.tsx", - "packages/dashboard/app/components/settings/sections/BackupsSection.tsx", - "packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx", - "packages/dashboard/app/components/settings/sections/AppearanceSection.tsx", - "packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx", ] as const; /** diff --git a/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx b/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx index 90bb44437c..d9c00881de 100644 --- a/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx @@ -1,60 +1,37 @@ -/** - * Agent Permissions section (U9 / KTD-10). - * - * Project-default agent permission policy editor plus the agent provisioning - * approval policy editor. The rule-completion helper is co-located (pure, used - * only here). Keys and editor wiring preserved verbatim from the original inline - * JSX. - */ import type { ReactNode } from "react"; import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES } from "@fusion/core"; import type { AgentPermissionPolicyRules } from "@fusion/core"; import { AgentPermissionPolicyEditor } from "../../AgentPermissionPolicyEditor"; import { AgentProvisioningPolicyEditor } from "../../AgentProvisioningPolicyEditor"; import type { SectionBaseProps } from "./context"; - +import { useTranslation } from "react-i18next"; function toCompleteAgentPermissionRules(rules?: Partial): AgentPermissionPolicyRules { - return AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.reduce((acc, category) => { - acc[category] = rules?.[category] ?? "allow"; - return acc; - }, {} as AgentPermissionPolicyRules); + return AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.reduce((acc, category) => { + acc[category] = rules?.[category] ?? "allow"; + return acc; + }, {} as AgentPermissionPolicyRules); } - export interface AgentPermissionsSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; + scopeBanner: ReactNode; } - export function AgentPermissionsSection({ scopeBanner, form, setForm }: AgentPermissionsSectionProps) { - return ( - <> + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

Agent Permissions

+

{t("settings.agentPermissions.agentPermissions", "Agent Permissions")}

- Per-agent settings override project defaults. Each category controls a separate approval gate. + {t("settings.agentPermissions.perAgentSettingsOverrideProjectDefaultsEachCategory", "Per-agent settings override project defaults. Each category controls a separate approval gate.")}
- - setForm((f) => ({ + setForm((f) => ({ ...f, defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules) }, - })) - } - /> + }))}/> -

Agent Provisioning Approvals

+

{t("settings.agentPermissions.agentProvisioningApprovals", "Agent Provisioning Approvals")}

- - Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete). - + {t("settings.agentPermissions.configureProjectLevelApprovalBehaviorForDurableProvisioning", " Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete). ")}
- setForm((f) => ({ ...f, agentProvisioning: next }))} - /> - - ); + setForm((f) => ({ ...f, agentProvisioning: next }))}/> + ); } - export default AgentPermissionsSection; diff --git a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx index 7ce95b1df9..04343c5025 100644 --- a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx @@ -1,82 +1,43 @@ -/** - * Appearance section (U9 / KTD-10). - * - * Theme mode, color theme, dashboard font scale, language, and the - * session-banner suppression toggle. The three-tier device-local prefs - * (theme/language/font scale) keep their hooks in the shell — this section only - * relays their current values and change callbacks, mirroring the original - * inline JSX exactly (it both writes the modal form AND calls the write-through - * callback so the live UI updates immediately). - */ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { ThemeMode, ColorTheme } from "@fusion/core"; import { ThemeSelector } from "../../ThemeSelector"; import { LanguageSelector } from "../../LanguageSelector"; import type { SectionBaseProps } from "./context"; - export interface AppearanceSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - themeMode: ThemeMode; - colorTheme: ColorTheme; - dashboardFontScalePct: number; - onThemeModeChange?: (mode: ThemeMode) => void; - onColorThemeChange?: (theme: ColorTheme) => void; - onDashboardFontScaleChange?: (scalePct: number) => void; - sessionBannersHidden: boolean; - setSessionBannersHidden: (hidden: boolean) => void; + scopeBanner: ReactNode; + themeMode: ThemeMode; + colorTheme: ColorTheme; + dashboardFontScalePct: number; + onThemeModeChange?: (mode: ThemeMode) => void; + onColorThemeChange?: (theme: ColorTheme) => void; + onDashboardFontScaleChange?: (scalePct: number) => void; + sessionBannersHidden: boolean; + setSessionBannersHidden: (hidden: boolean) => void; } - -export function AppearanceSection({ - scopeBanner, - setForm, - themeMode, - colorTheme, - dashboardFontScalePct, - onThemeModeChange, - onColorThemeChange, - onDashboardFontScaleChange, - sessionBannersHidden, - setSessionBannersHidden, -}: AppearanceSectionProps) { - const { t } = useTranslation("app"); - return ( - <> +export function AppearanceSection({ scopeBanner, setForm, themeMode, colorTheme, dashboardFontScalePct, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) { + const { t } = useTranslation("app"); + return (<> {scopeBanner}

{t("settings.appearance.title", "Appearance")}

- { - setForm((f) => ({ ...f, themeMode: mode })); - onThemeModeChange?.(mode); - }} - onColorThemeChange={(theme) => { - setForm((f) => ({ ...f, colorTheme: theme })); - onColorThemeChange?.(theme); - }} - onDashboardFontScaleChange={(scalePct) => { - setForm((f) => ({ ...f, dashboardFontScalePct: scalePct })); - onDashboardFontScaleChange?.(scalePct); - }} - /> + { + setForm((f) => ({ ...f, themeMode: mode })); + onThemeModeChange?.(mode); + }} onColorThemeChange={(theme) => { + setForm((f) => ({ ...f, colorTheme: theme })); + onColorThemeChange?.(theme); + }} onDashboardFontScaleChange={(scalePct) => { + setForm((f) => ({ ...f, dashboardFontScalePct: scalePct })); + onDashboardFontScaleChange?.(scalePct); + }}/>
- - Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. - + {t("settings.appearance.suppressTheLdquoNeedsYourInputRdquoBanner", " Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. ")}
- - ); + ); } - export default AppearanceSection; diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index 4efbc9a877..3936a6da22 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -1,15 +1,3 @@ -/** - * Authentication section (U9 / KTD-10). - * - * Provider sign-in surface: CLI-backed provider cards, OAuth login/logout - * flows (device codes, manual code entry, login instructions), API-key - * entry/clear, plugin-contributed provider/integration cards, and the custom - * providers manager. This section is scope-less (auth changes apply - * immediately, not via the modal save), so it owns no form state — but it has a - * large set of shell-owned auth state and handlers, relayed via the `auth` prop - * bag. Component imports and pure utilities (clipboard, token-query) are - * imported directly. Behavior, test ids, and i18n keys are preserved verbatim. - */ import type { Dispatch, SetStateAction } from "react"; import type { AuthProvider, ManualOAuthCodeInfo, OAuthDeviceCodeInfo } from "../../../api"; import type { ToastType } from "../../../hooks/useToast"; @@ -24,405 +12,214 @@ import { OAuthManualCodeForm } from "../../OAuthManualCodeForm"; import { CustomProvidersSection } from "../../CustomProvidersSection"; import { copyTextToClipboard } from "../../../utils/copyToClipboard"; import { appendTokenQuery } from "../../../auth"; - export interface AuthenticationSectionData { - projectId?: string; - addToast: (message: string, type?: ToastType) => void; - authProviders: AuthProvider[]; - authLoading: boolean; - authActionInProgress: string | null; - apiKeyInputs: Record; - setApiKeyInputs: Dispatch>>; - apiKeyErrors: Record; - opencodeApiKeyRefreshStatus: Record; - deviceCodes: Record; - loginInstructions: Record; - manualCodeConfigs: Record; - manualCodeInputs: Record; - setManualCodeInputs: Dispatch>>; - manualCodeSubmitInProgress: string | null; - loadAuthStatus: () => void | Promise; - handleLogin: (providerId: string) => void; - handleLogout: (providerId: string) => void; - handleCancelLogin: (providerId: string) => void; - handleSaveApiKey: (providerId: string) => void; - handleClearApiKey: (providerId: string) => void; - handleSubmitManualCode: (providerId: string) => void | Promise; - onReopenOnboarding?: () => void; + projectId?: string; + addToast: (message: string, type?: ToastType) => void; + authProviders: AuthProvider[]; + authLoading: boolean; + authActionInProgress: string | null; + apiKeyInputs: Record; + setApiKeyInputs: Dispatch>>; + apiKeyErrors: Record; + opencodeApiKeyRefreshStatus: Record; + deviceCodes: Record; + loginInstructions: Record; + manualCodeConfigs: Record; + manualCodeInputs: Record; + setManualCodeInputs: Dispatch>>; + manualCodeSubmitInProgress: string | null; + loadAuthStatus: () => void | Promise; + handleLogin: (providerId: string) => void; + handleLogout: (providerId: string) => void; + handleCancelLogin: (providerId: string) => void; + handleSaveApiKey: (providerId: string) => void; + handleClearApiKey: (providerId: string) => void; + handleSubmitManualCode: (providerId: string) => void | Promise; + onReopenOnboarding?: () => void; } - export interface AuthenticationSectionProps { - auth: AuthenticationSectionData; + auth: AuthenticationSectionData; } - export function AuthenticationSection({ auth }: AuthenticationSectionProps) { - const { t } = useTranslation("app"); - const { - projectId, - addToast, - authProviders, - authLoading, - authActionInProgress, - apiKeyInputs, - setApiKeyInputs, - apiKeyErrors, - opencodeApiKeyRefreshStatus, - deviceCodes, - loginInstructions, - manualCodeConfigs, - manualCodeInputs, - setManualCodeInputs, - manualCodeSubmitInProgress, - loadAuthStatus, - handleLogin, - handleLogout, - handleCancelLogin, - handleSaveApiKey, - handleClearApiKey, - handleSubmitManualCode, - onReopenOnboarding, - } = auth; - - // CLI-backed providers render their own compact card; filter them out of the - // standard OAuth/API-key sort and render alongside. - const cliAuthProviders = authProviders.filter((p) => p.type === "cli"); - const nonCliProviders = authProviders.filter((p) => p.type !== "cli"); - const sortedProviders = [...nonCliProviders].sort((a, b) => { - if (a.authenticated !== b.authenticated) { - return a.authenticated ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - const authenticatedProviders = sortedProviders.filter((p) => p.authenticated); - const unauthenticatedProviders = sortedProviders.filter((p) => !p.authenticated); - - const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); - const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli"); - const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp"); - const claudeCliCard = claudeCliProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const cursorCliCard = cursorCliProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const llamaCppCard = llamaCppProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const showAuthenticatedGroup = - authenticatedProviders.length > 0 || - (claudeCliProvider?.authenticated ?? false) || - (cursorCliProvider?.authenticated ?? false) || - (llamaCppProvider?.authenticated ?? false); - const showAvailableGroup = - unauthenticatedProviders.length > 0 || - (claudeCliProvider && !claudeCliProvider.authenticated) || - (cursorCliProvider && !cursorCliProvider.authenticated) || - (llamaCppProvider && !llamaCppProvider.authenticated); - - return ( - <> + const { t } = useTranslation("app"); + const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth; + // CLI-backed providers render their own compact card; filter them out of the + // standard OAuth/API-key sort and render alongside. + const cliAuthProviders = authProviders.filter((p) => p.type === "cli"); + const nonCliProviders = authProviders.filter((p) => p.type !== "cli"); + const sortedProviders = [...nonCliProviders].sort((a, b) => { + if (a.authenticated !== b.authenticated) { + return a.authenticated ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + const authenticatedProviders = sortedProviders.filter((p) => p.authenticated); + const unauthenticatedProviders = sortedProviders.filter((p) => !p.authenticated); + const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); + const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli"); + const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp"); + const claudeCliCard = claudeCliProvider ? ( { + void loadAuthStatus(); + }}/>) : null; + const cursorCliCard = cursorCliProvider ? ( { + void loadAuthStatus(); + }}/>) : null; + const llamaCppCard = llamaCppProvider ? ( { + void loadAuthStatus(); + }}/>) : null; + const showAuthenticatedGroup = authenticatedProviders.length > 0 || + (claudeCliProvider?.authenticated ?? false) || + (cursorCliProvider?.authenticated ?? false) || + (llamaCppProvider?.authenticated ?? false); + const showAvailableGroup = unauthenticatedProviders.length > 0 || + (claudeCliProvider && !claudeCliProvider.authenticated) || + (cursorCliProvider && !cursorCliProvider.authenticated) || + (llamaCppProvider && !llamaCppProvider.authenticated); + return (<>

{t("settings.auth.title", "Authentication")}

- {authLoading ? ( -
{t("settings.auth.loadingStatus", "Loading authentication status…")}
- ) : authProviders.length === 0 ? ( -
+ {authLoading ? (
{t("settings.auth.loadingStatus", "Loading authentication status…")}
) : authProviders.length === 0 ? (
{t("settings.auth.noProviders", "No providers available")} -
- ) : ( -
- { void loadAuthStatus(); } }} - /> - { void loadAuthStatus(); } }} - /> - {!showAuthenticatedGroup && ( -
+
) : (
+ { void loadAuthStatus(); } }}/> + { void loadAuthStatus(); } }}/> + {!showAuthenticatedGroup && (
{t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")} -
- )} - {showAuthenticatedGroup && ( -
+
)} + {showAuthenticatedGroup && (
{t("settings.auth.groupAuthenticated", "Authenticated")}
{claudeCliProvider?.authenticated && claudeCliCard} {cursorCliProvider?.authenticated && cursorCliCard} {llamaCppProvider?.authenticated && llamaCppCard} - {authenticatedProviders.map((provider) => ( -
+ {authenticatedProviders.map((provider) => (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} -
- {provider.type === "api_key" ? ( -
+ {provider.type === "api_key" ? (
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} - disabled={authActionInProgress === provider.id} - /> - {provider.authenticated && !apiKeyInputs[provider.id] ? ( - - ) : ( - ) : ( - )} + )}
- {authActionInProgress === provider.id && ( - {t("settings.auth.savingKey", "Saving…")} - )} - {apiKeyErrors[provider.id] && ( - {apiKeyErrors[provider.id]} - )} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - + {authActionInProgress === provider.id && ({t("settings.auth.savingKey", "Saving…")})} + {apiKeyErrors[provider.id] && ({apiKeyErrors[provider.id]})} + {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( {opencodeApiKeyRefreshStatus[provider.id].message} - - )} -
- ) : ( -
- {authActionInProgress === provider.id ? ( -
) : (
+ {authActionInProgress === provider.id ? ( - ) : provider.loginInProgress ? ( -
+ ) : provider.loginInProgress ? (
-
- ) : ( -
) : ( - )} -
- )} + )} +
)}
-
- ))} -
- )} - {showAvailableGroup && ( -
+
))} +
)} + {showAvailableGroup && (
{t("settings.auth.groupAvailable", "Available")}
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard} {cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard} {llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard} - {unauthenticatedProviders.map((provider) => ( -
+ {unauthenticatedProviders.map((provider) => (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} -
- {provider.type === "api_key" ? ( -
+ {provider.type === "api_key" ? (
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} - disabled={authActionInProgress === provider.id} - /> -
- {authActionInProgress === provider.id && ( - {t("settings.auth.savingKey", "Saving…")} - )} - {apiKeyErrors[provider.id] && ( - {apiKeyErrors[provider.id]} - )} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - + {authActionInProgress === provider.id && ({t("settings.auth.savingKey", "Saving…")})} + {apiKeyErrors[provider.id] && ({apiKeyErrors[provider.id]})} + {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( {opencodeApiKeyRefreshStatus[provider.id].message} - - )} -
- ) : ( -
- {authActionInProgress === provider.id ? ( -
) : (
+ {authActionInProgress === provider.id ? ( - ) : provider.loginInProgress ? ( -
+ ) : provider.loginInProgress ? (
-
- ) : ( -
) : ( - )} - {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( -
+ )} + {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}
{deviceCodes[provider.id].userCode}
- -
-
- )} - {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( - - )} - {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( - setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} - onSubmit={() => void handleSubmitManualCode(provider.id)} - prompt={manualCodeConfigs[provider.id].prompt} - placeholder={manualCodeConfigs[provider.id].placeholder} - helpText={manualCodeConfigs[provider.id].helpText} - disabled={manualCodeSubmitInProgress === provider.id} - submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} - data-testid={`auth-manual-code-${provider.id}`} - /> - )} -
- )} +
)} + {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ()} + {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id)} prompt={manualCodeConfigs[provider.id].prompt} placeholder={manualCodeConfigs[provider.id].placeholder} helpText={manualCodeConfigs[provider.id].helpText} disabled={manualCodeSubmitInProgress === provider.id} submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${provider.id}`}/>)} +
)}
-
- ))} -
- )} -
- )} +
))} +
)} +
)} {t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")} - {onReopenOnboarding && ( -
- {t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")} -
- )} + )} - - ); + ); } - export default AuthenticationSection; diff --git a/packages/dashboard/app/components/settings/sections/BackupsSection.tsx b/packages/dashboard/app/components/settings/sections/BackupsSection.tsx index 66d6fe7129..9edbfdbf02 100644 --- a/packages/dashboard/app/components/settings/sections/BackupsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/BackupsSection.tsx @@ -1,229 +1,116 @@ -/** - * Backups section (U9 / KTD-10). - * - * Project-scoped database-backup and memory-backup schedules/retention/dirs plus - * the current-backups summary and the manual "Backup Now" action. The backup - * info fetch and the backup-now handler live in the shell (they touch the API and - * toast) and are relayed as props. Keys, validation regexes, and conditional - * disabling preserved verbatim from the original inline JSX. - */ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { BackupListResponse } from "../../../api"; import type { SectionBaseProps } from "./context"; - export interface BackupsSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - backupInfo: BackupListResponse | null; - backupLoading: boolean; - onBackupNow: () => void; + scopeBanner: ReactNode; + backupInfo: BackupListResponse | null; + backupLoading: boolean; + onBackupNow: () => void; } - export function BackupsSection({ scopeBanner, form, setForm, backupInfo, backupLoading, onBackupNow }: BackupsSectionProps) { - const { t } = useTranslation("app"); - return ( - <> + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

Database Backups

+

{t("settings.backups.databaseBackups", "Database Backups")}

- When enabled, the database is backed up automatically on a schedule + setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))}/>{t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups ")} + {t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule")}
- - - setForm((f) => ({ ...f, autoBackupSchedule: e.target.value })) - } - disabled={!form.autoBackupEnabled} - /> - - Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). - Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) - - {form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && ( - Invalid cron expression format - )} + + setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))} disabled={!form.autoBackupEnabled}/> + {t("settings.backups.cronExpressionForBackupTimingDefault02", " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) ")} + {form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && ({t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")})}
- - { + + { const val = e.target.value; setForm((f) => ({ ...f, autoBackupRetention: val === "" ? undefined : Number(val) })); - }} - disabled={!form.autoBackupEnabled} - /> - Number of backup files to keep (oldest are deleted first). Range: 1-100. - {form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && ( - Must be between 1 and 100 - )} + }} disabled={!form.autoBackupEnabled}/> + {t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100.")} + {form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && ({t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")})}
- - - setForm((f) => ({ ...f, autoBackupDir: e.target.value })) - } - disabled={!form.autoBackupEnabled} - /> - Directory for backup files, relative to project root - {form.autoBackupDir && form.autoBackupDir.includes("..") && ( - Path cannot contain parent directory traversal (..) - )} + + setForm((f) => ({ ...f, autoBackupDir: e.target.value }))} disabled={!form.autoBackupEnabled}/> + {t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to project root")} + {form.autoBackupDir && form.autoBackupDir.includes("..") && ({t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")})}
-

Memory Backups

+

{t("settings.backups.memoryBackups", "Memory Backups")}

- When enabled, project and agent memory files are backed up automatically on a schedule. + setForm((f) => ({ ...f, memoryBackupEnabled: e.target.checked }))}/>{t("settings.backups.enableAutomaticMemoryBackups", " Enable automatic memory backups ")} + {t("settings.backups.whenEnabledProjectAndAgentMemoryFilesAre", "When enabled, project and agent memory files are backed up automatically on a schedule.")}
- - setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))} - disabled={!form.memoryBackupEnabled} - /> - Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM). - {form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && ( - Invalid cron expression format - )} + + setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))} disabled={!form.memoryBackupEnabled}/> + {t("settings.backups.cronExpressionForMemoryBackupTimingDefault0", "Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).")} + {form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && ({t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")})}
- - { + + { const val = e.target.value; setForm((f) => ({ ...f, memoryBackupRetention: val === "" ? undefined : Number(val) })); - }} - disabled={!form.memoryBackupEnabled} - /> - Number of memory backups to keep (oldest are deleted first). Range: 1-100. - {form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && ( - Must be between 1 and 100 - )} + }} disabled={!form.memoryBackupEnabled}/> + {t("settings.backups.numberOfMemoryBackupsToKeepOldestAre", "Number of memory backups to keep (oldest are deleted first). Range: 1-100.")} + {form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && ({t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")})}
- - setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))} - disabled={!form.memoryBackupEnabled} - /> - Directory for memory backups, relative to project root. - {form.memoryBackupDir && form.memoryBackupDir.includes("..") && ( - Path cannot contain parent directory traversal (..) - )} + + setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))} disabled={!form.memoryBackupEnabled}/> + {t("settings.backups.directoryForMemoryBackupsRelativeToProjectRoot", "Directory for memory backups, relative to project root.")} + {form.memoryBackupDir && form.memoryBackupDir.includes("..") && ({t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")})}
- - setForm((f) => ({ ...f, memoryBackupScope: e.target.value as "project" | "agents" | "all" }))} disabled={!form.memoryBackupEnabled}> + + +
- {backupLoading ? ( -
Loading backup info…
- ) : backupInfo ? ( -
- + {backupLoading ? (
{t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")}
) : backupInfo ? (
+
{backupInfo.count} - backups + {t("settings.backups.backups", "backups")}
{backupInfo.totalSize > 1024 * 1024 - ? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB` - : `${(backupInfo.totalSize / 1024).toFixed(1)} KB`} + ? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB` + : `${(backupInfo.totalSize / 1024).toFixed(1)} KB`} - total size + {t("settings.backups.totalSize", "total size")}
- {backupInfo.backups.length > 0 && ( -
- View {backupInfo.backups.length} backup(s) + {backupInfo.backups.length > 0 && (
+ {t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " backup(s)")}
    - {backupInfo.backups.slice(0, 10).map((backup) => ( -
  • + {backupInfo.backups.slice(0, 10).map((backup) => (
  • {backup.filename} {backup.size > 1024 * 1024 ? `${(backup.size / (1024 * 1024)).toFixed(1)} MB` : `${(backup.size / 1024).toFixed(1)} KB`} -
  • - ))} - {backupInfo.backups.length > 10 && ( -
  • ...and {backupInfo.backups.length - 10} more
  • - )} + ))} + {backupInfo.backups.length > 10 && (
  • {t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}
  • )}
-
- )} -
- ) : null} + )} +
) : null}
-
- - ); + ); } - export default BackupsSection; diff --git a/packages/dashboard/app/components/settings/sections/CommandsSection.tsx b/packages/dashboard/app/components/settings/sections/CommandsSection.tsx index 74765a36cf..64d0e29e68 100644 --- a/packages/dashboard/app/components/settings/sections/CommandsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/CommandsSection.tsx @@ -1,49 +1,24 @@ -/** - * Commands section (U9 / KTD-10). - * - * Project-scoped test/build command inputs injected into generated task specs. - * Behavior and keys preserved verbatim from the original inline JSX. - */ import type { ReactNode } from "react"; import type { SectionBaseProps } from "./context"; - +import { useTranslation } from "react-i18next"; export interface CommandsSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; + scopeBanner: ReactNode; } - export function CommandsSection({ scopeBanner, form, setForm }: CommandsSectionProps) { - return ( - <> + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

Commands

+

{t("settings.commands.commands", "Commands")}

- - - setForm((f) => ({ ...f, testCommand: e.target.value || undefined })) - } - /> - Command used to run tests — injected into generated task specs + + setForm((f) => ({ ...f, testCommand: e.target.value || undefined }))}/> + {t("settings.commands.commandUsedToRunTestsInjectedIntoGenerated", "Command used to run tests \u2014 injected into generated task specs")}
- - - setForm((f) => ({ ...f, buildCommand: e.target.value || undefined })) - } - /> - Command used to build the project — injected into generated task specs + + setForm((f) => ({ ...f, buildCommand: e.target.value || undefined }))}/> + {t("settings.commands.commandUsedToBuildTheProjectInjectedInto", "Command used to build the project \u2014 injected into generated task specs")}
- - ); + ); } - export default CommandsSection; diff --git a/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx b/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx index c80f4062c5..c4ebb2f900 100644 --- a/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx @@ -1,94 +1,57 @@ -/** - * Experimental Features section (U9 / KTD-10). - * - * Renders the union of well-known experimental flags (always shown) and any - * custom flags present in settings, canonicalizing legacy aliases so each - * feature renders exactly one row. Toggling writes the canonical key and clears - * its legacy alias. The known-feature catalog and alias helpers live in the - * shell module and are passed in so this section stays presentational. - */ import type { ReactNode } from "react"; import type { SectionBaseProps } from "./context"; - +import { useTranslation } from "react-i18next"; export interface ExperimentalSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - /** Display labels for well-known features (always rendered). */ - knownFeatures: Record; - /** Map of legacy alias key -> canonical key. */ - legacyAliases: Record; - /** Canonicalize a possibly-legacy feature key. */ - getCanonicalKey: (key: string) => string; - /** Whether a feature is enabled, honoring legacy aliases. */ - isFeatureEnabled: (features: Record, key: string) => boolean; + scopeBanner: ReactNode; + /** Display labels for well-known features (always rendered). */ + knownFeatures: Record; + /** Map of legacy alias key -> canonical key. */ + legacyAliases: Record; + /** Canonicalize a possibly-legacy feature key. */ + getCanonicalKey: (key: string) => string; + /** Whether a feature is enabled, honoring legacy aliases. */ + isFeatureEnabled: (features: Record, key: string) => boolean; } - -export function ExperimentalSection({ - scopeBanner, - form, - setForm, - knownFeatures, - legacyAliases, - getCanonicalKey, - isFeatureEnabled, -}: ExperimentalSectionProps) { - const experimentalFeatures = form.experimentalFeatures ?? {}; - const allFeatureKeys = Array.from( - new Set([ - ...Object.keys(knownFeatures), - ...Object.keys(experimentalFeatures).map(getCanonicalKey), - ]), - ).sort((a, b) => a.localeCompare(b)); - const featureFlags = allFeatureKeys.map( - (key) => [key, isFeatureEnabled(experimentalFeatures, key)] as const, - ); - - return ( - <> +export function ExperimentalSection({ scopeBanner, form, setForm, knownFeatures, legacyAliases, getCanonicalKey, isFeatureEnabled, }: ExperimentalSectionProps) { + const { t } = useTranslation("app"); + const experimentalFeatures = form.experimentalFeatures ?? {}; + const allFeatureKeys = Array.from(new Set([ + ...Object.keys(knownFeatures), + ...Object.keys(experimentalFeatures).map(getCanonicalKey), + ])).sort((a, b) => a.localeCompare(b)); + const featureFlags = allFeatureKeys.map((key) => [key, isFeatureEnabled(experimentalFeatures, key)] as const); + return (<> {scopeBanner} -

Experimental Features

+

{t("settings.experimental.experimentalFeatures", "Experimental Features")}

- - Experimental features are early capabilities that are not yet fully stable. - Enable them to test new functionality, but be aware they may change or be removed. - + {t("settings.experimental.experimentalFeaturesAreEarlyCapabilitiesThatAreNot", " Experimental features are early capabilities that are not yet fully stable. Enable them to test new functionality, but be aware they may change or be removed. ")}
- +
- {featureFlags.map(([key, enabled]) => ( - ))}
- - ); + ); } - export default ExperimentalSection; diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 88ab6325c2..5474de89c7 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -1,15 +1,3 @@ -/** - * Project General section (U9 / KTD-10). - * - * Project-scoped general settings: task prefix, default workflow, ephemeral - * agents, completion-documentation mode, quick-chat FAB, chat-history/mail/log - * retention, chat-room compaction tuning, capacity-risk banner, and GitHub - * tracking defaults. The prefix-validation error and the project tracking-repo - * options are owned by the shell (the prefix error gates Save; the repo options - * are fetched once) and relayed as props. Keys, validation regexes, and the - * cross-field summarizer hint are preserved verbatim from the original inline - * JSX. - */ import { useEffect, useMemo, useState, type ReactNode } from "react"; import type { WorkflowDefinition } from "@fusion/core"; import { ProjectDefaultWorkflowField } from "../../WorkflowSelector"; @@ -17,373 +5,206 @@ import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoS import { fetchWorkflows } from "../../../api"; import type { ToastType } from "../../../hooks/useToast"; import type { SectionBaseProps } from "./context"; - +import { useTranslation } from "react-i18next"; export interface GeneralSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - projectId?: string; - addToast: (message: string, type?: ToastType) => void; - prefixError: string | null; - setPrefixError: (value: string | null) => void; - projectTrackingRepoOptions: TrackingRepoOption[]; - projectTrackingRepoLoading: boolean; - projectTrackingRepoError: string | null; + scopeBanner: ReactNode; + projectId?: string; + addToast: (message: string, type?: ToastType) => void; + prefixError: string | null; + setPrefixError: (value: string | null) => void; + projectTrackingRepoOptions: TrackingRepoOption[]; + projectTrackingRepoLoading: boolean; + projectTrackingRepoError: string | null; } - -export function GeneralSection({ - scopeBanner, - form, - setForm, - projectId, - addToast, - prefixError, - setPrefixError, - projectTrackingRepoOptions, - projectTrackingRepoLoading, - projectTrackingRepoError, -}: GeneralSectionProps) { - const [builtinWorkflows, setBuiltinWorkflows] = useState([]); - - useEffect(() => { - let cancelled = false; - fetchWorkflows(projectId, { includeDisabledBuiltins: true }) - .then((workflows) => { - if (!cancelled) { - setBuiltinWorkflows( - workflows.filter((workflow) => workflow.id.startsWith("builtin:") && workflow.kind !== "fragment"), - ); - } - }) - .catch(() => { - if (!cancelled) setBuiltinWorkflows([]); - }); - return () => { - cancelled = true; +export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast, prefixError, setPrefixError, projectTrackingRepoOptions, projectTrackingRepoLoading, projectTrackingRepoError, }: GeneralSectionProps) { + const { t } = useTranslation("app"); + const [builtinWorkflows, setBuiltinWorkflows] = useState([]); + useEffect(() => { + let cancelled = false; + fetchWorkflows(projectId, { includeDisabledBuiltins: true }) + .then((workflows) => { + if (!cancelled) { + setBuiltinWorkflows(workflows.filter((workflow) => workflow.id.startsWith("builtin:") && workflow.kind !== "fragment")); + } + }) + .catch(() => { + if (!cancelled) + setBuiltinWorkflows([]); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + const enabledBuiltinWorkflowIds = useMemo(() => { + const configured = Array.isArray(form.enabledBuiltinWorkflowIds) ? form.enabledBuiltinWorkflowIds : undefined; + return new Set(configured ?? builtinWorkflows.map((workflow) => workflow.id)); + }, [builtinWorkflows, form.enabledBuiltinWorkflowIds]); + const setBuiltinWorkflowEnabled = (workflowId: string, enabled: boolean) => { + setForm((f) => { + const allIds = builtinWorkflows.map((workflow) => workflow.id); + const current = new Set(Array.isArray(f.enabledBuiltinWorkflowIds) ? f.enabledBuiltinWorkflowIds : allIds); + if (enabled) { + current.add(workflowId); + } + else { + current.delete(workflowId); + } + const nextIds = allIds.filter((id) => current.has(id)); + return { + ...f, + enabledBuiltinWorkflowIds: nextIds.length === allIds.length ? undefined : nextIds, + }; + }); }; - }, [projectId]); - - const enabledBuiltinWorkflowIds = useMemo(() => { - const configured = Array.isArray(form.enabledBuiltinWorkflowIds) ? form.enabledBuiltinWorkflowIds : undefined; - return new Set(configured ?? builtinWorkflows.map((workflow) => workflow.id)); - }, [builtinWorkflows, form.enabledBuiltinWorkflowIds]); - - const setBuiltinWorkflowEnabled = (workflowId: string, enabled: boolean) => { - setForm((f) => { - const allIds = builtinWorkflows.map((workflow) => workflow.id); - const current = new Set(Array.isArray(f.enabledBuiltinWorkflowIds) ? f.enabledBuiltinWorkflowIds : allIds); - if (enabled) { - current.add(workflowId); - } else { - current.delete(workflowId); - } - const nextIds = allIds.filter((id) => current.has(id)); - return { - ...f, - enabledBuiltinWorkflowIds: nextIds.length === allIds.length ? undefined : nextIds, - }; - }); - }; - - return ( - <> + return (<> {scopeBanner} -

General

+

{t("settings.general.general", "General")}

- - { + + { const val = e.target.value; setForm((f) => ({ ...f, taskPrefix: val || undefined })); if (val && !/^[A-Z]{1,10}$/.test(val)) { - setPrefixError("Prefix must be 1–10 uppercase letters"); - } else { - setPrefixError(null); + setPrefixError(t("settings.general.prefixMustBe110UppercaseLetters", "Prefix must be 1–10 uppercase letters")); } - }} - /> + else { + setPrefixError(null); + } + }}/> {prefixError && {prefixError}} - {!prefixError && Prefix for new task IDs (e.g. KB, PROJ)} + {!prefixError && {t("settings.general.prefixForNewTaskIDsEGKB", "Prefix for new task IDs (e.g. KB, PROJ)")}}
- - New tasks inherit this custom workflow's steps (overridable per task) + + {t("settings.general.newTasksInheritThisCustomWorkflowsStepsOverridable", "New tasks inherit this custom workflow's steps (overridable per task)")}
- {builtinWorkflows.length > 0 && ( -
- + {builtinWorkflows.length > 0 && (
+
- {builtinWorkflows.map((workflow) => ( - ))}
- Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. -
- )} + {t("settings.general.disabledBuiltInWorkflowsAreHiddenFromWorkflow", "Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve.")} +
)}
- - When enabled (default), Fusion spawns short-lived executor-FN-XXXX agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued. - + setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))}/>{t("settings.general.useEphemeralTaskWorkerAgents", " Use ephemeral task-worker agents ")} + {t("settings.general.whenEnabledDefaultFusionSpawnsShortLived", " When enabled (default), Fusion spawns short-lived ")}executor-FN-XXXX{t("settings.general.agentsToRunEachTaskWhenDisabledOnly", " agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued. ")}
- - setForm((f) => ({ + ...f, + completionDocumentationMode: e.target.value as "off" | "changeset" | "changelog", + }))}> + + + - - Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow - .changeset workflows, or changelog mode when contributors should update an existing changelog file. - + {t("settings.general.controlsHowFutureTaskSpecsHandleReleaseNote", " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ")}.changeset{t("settings.general.workflowsOrChangelogModeWhenContributorsShouldUpdate", " workflows, or changelog mode when contributors should update an existing changelog file. ")}
- Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation. + setForm((f) => ({ ...f, showQuickChatFAB: e.target.checked }))}/>{t("settings.general.showQuickChatButton", " Show quick chat button ")} + {t("settings.general.showTheFloatingChatButtonInTheDashboard", "Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.")}
-

Chat history

+

{t("settings.general.chatHistory", "Chat history")}

- - setForm((f) => ({ ...f, chatAutoCleanupDays: Number(e.target.value) || 0 }))}> + + + + + + - Delete chat sessions and rooms that have been idle for this many days. Default: Off. + {t("settings.general.deleteChatSessionsAndRoomsThatHaveBeen", "Delete chat sessions and rooms that have been idle for this many days. Default: Off.")}
- - setForm((f) => ({ ...f, mailAutoCleanupDays: Number(e.target.value) || 0 }))}> + + + + + + - Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting. + {t("settings.general.deleteInboxOutboxMessagesOlderThanThisMany", "Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.")}
- - setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))}> + + + + + + - - Lowering this window means Reliability metrics/charts and the Activity feed will not show history older - than the selected range. Per-task task detail history is unaffected. Default: 30 days. - + {t("settings.general.loweringThisWindowMeansReliabilityMetricsChartsAnd", " Lowering this window means Reliability metrics/charts and the Activity feed will not show history older than the selected range. Per-task task detail history is unaffected. Default: 30 days. ")}
-

Chat Rooms

+

{t("settings.general.chatRooms", "Chat Rooms")}

- - - setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined })) - } - /> - Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25. + + setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined }))}/> + {t("settings.general.numberOfMostRecentChatRoomMessagesKept", "Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.")}
- - - setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined })) - } - /> - Upper bound on messages fetched from the room store for compaction consideration. Default: 200. + + setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined }))}/> + {t("settings.general.upperBoundOnMessagesFetchedFromTheRoom", "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.")}
- - - setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined })) - } - /> - Hard cap on the synthesized "Earlier room context" summary block. Default: 3000. + + setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined }))}/> + {t("settings.general.hardCapOnTheSynthesizedEarlierRoomContext", "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000.")}
-

Capacity Risk Banner

+

{t("settings.general.capacityRiskBanner", "Capacity Risk Banner")}

- Warn on the board when todo work exceeds the threshold and no idle agents are available. + setForm((f) => ({ ...f, capacityRiskBannerEnabled: e.target.checked }))}/>{t("settings.general.showCapacityRiskBanner", " Show capacity risk banner ")} + {t("settings.general.warnOnTheBoardWhenTodoWorkExceeds", "Warn on the board when todo work exceeds the threshold and no idle agents are available.")}
- - - setForm((f) => ({ - ...f, - capacityRiskTodoThreshold: - e.target.value === "" - ? 0 - : Math.max(0, Number.parseInt(e.target.value, 10) || 0), - })) - } - /> - Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled. + + setForm((f) => ({ + ...f, + capacityRiskTodoThreshold: e.target.value === "" + ? 0 + : Math.max(0, Number.parseInt(e.target.value, 10) || 0), + }))}/> + {t("settings.general.bannerFiresWhenTodoCountIsStrictlyGreater", "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.")}
-

GitHub Tracking

+

{t("settings.general.gitHubTracking", "GitHub Tracking")}

- - setForm((f) => ({ + ...f, + githubTrackingEnabledByDefault: e.target.value === "new-tasks", + }))}> + + - - Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. - - - Tracking issues use this task's title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. - {!form.autoSummarizeTitles && !form.useAiMergeCommitSummary && !form.githubTrackingEnabledByDefault - ? " Enable summarization in Project Models to configure that model." + {t("settings.general.controlsWhetherNewlyCreatedTasksHaveGitHubIssue", " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ")} + {t("settings.general.trackingIssuesUseThisTaskAposSTitle", " Tracking issues use this task's title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. ")}{!form.autoSummarizeTitles && !form.useAiMergeCommitSummary && !form.githubTrackingEnabledByDefault + ? t("settings.general.enableSummarizationInProjectModelsToConfigureThatModel", " Enable summarization in Project Models to configure that model.") : ""}
- - - setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) - } - /> - Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank. + + setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/> + {t("settings.general.defaultRepoUsedWhenCreatingGitHubIssuesFor", "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.")}
- - When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. - + setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))}/>{t("settings.general.searchTheTrackingRepoForLikelyDuplicatesBefore", " Search the tracking repo for likely duplicates before opening a new issue ")} + {t("settings.general.whenEnabledFusionChecksOpenAndClosedIssues", " When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. ")}
- - ); + ); } - export default GeneralSection; diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index 380ffecc47..2c78d2e167 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -1,176 +1,71 @@ -/** - * Global General section (U9 / KTD-10). - * - * The global default tracking repo, CLI binary panel, agent-log persistence - * toggles (tool output + thinking logs), the `fn` binary probe toggle, and the - * update-check controls. The tracking-repo option list/loading/error live in - * the shell (fetched on demand) and are relayed as props. The thinking-log - * resolution helper is imported directly from core. - */ import type { ReactNode } from "react"; import { resolvePersistAgentThinkingLog } from "@fusion/core"; import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect"; import { CliBinaryPanel } from "../../CliBinaryPanel"; import type { SectionBaseProps } from "./context"; - +import { useTranslation } from "react-i18next"; export interface GlobalGeneralSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - globalTrackingRepoOptions: TrackingRepoOption[]; - globalTrackingRepoLoading: boolean; - globalTrackingRepoError: string | null; + scopeBanner: ReactNode; + globalTrackingRepoOptions: TrackingRepoOption[]; + globalTrackingRepoLoading: boolean; + globalTrackingRepoError: string | null; } - -export function GlobalGeneralSection({ - scopeBanner, - form, - setForm, - globalTrackingRepoOptions, - globalTrackingRepoLoading, - globalTrackingRepoError, -}: GlobalGeneralSectionProps) { - return ( - <> +export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackingRepoOptions, globalTrackingRepoLoading, globalTrackingRepoError, }: GlobalGeneralSectionProps) { + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

General

+

{t("settings.globalGeneral.general", "General")}

- - - setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) - } - /> - Projects inherit this value when they do not set a project default tracking repo. + + setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/> + {t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")}
- - When disabled, tool rows are still logged but detailed tool payloads are omitted. - Very large tool payloads may still be clipped even when this stays enabled. - + setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}/>{t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs ")} + {t("settings.globalGeneral.whenDisabledToolRowsAreStillLoggedBut", " When disabled, tool rows are still logged but detailed tool payloads are omitted. Very large tool payloads may still be clipped even when this stays enabled. ")}
-
Save AI thinking logs
+
{t("settings.globalGeneral.saveAIThinkingLogs", "Save AI thinking logs")}
+ setForm((f) => ({ ...f, persistAgentThinkingLogPermanent: e.target.checked }))}/>{t("settings.globalGeneral.saveAIThinkingForPermanentAgents", " Save AI thinking for permanent agents ")} - - Leave both thinking toggles off to keep the original default behavior. - This only controls persisted thinking rows and does not affect assistant text or tool rows. - + setForm((f) => ({ ...f, persistAgentThinkingLogEphemeral: e.target.checked }))}/>{t("settings.globalGeneral.saveAIThinkingForEphemeralTaskWorkerAgents", " Save AI thinking for ephemeral / task-worker agents ")} + {t("settings.globalGeneral.leaveBothThinkingTogglesOffToKeepThe", " Leave both thinking toggles off to keep the original default behavior. This only controls persisted ")}thinking{t("settings.globalGeneral.rowsAndDoesNotAffectAssistantTextOr", " rows and does not affect assistant text or tool rows. ")}
- - When enabled, the dashboard probes for a globally-installed{" "} - fn / fusion CLI by spawning{" "} - <bin> --version. Disable this if your local - dev process is the source of truth and you don't want any - outdated globally-installed binary executed during the probe. - + setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}/>{t("settings.globalGeneral.checkForThe", " Check for the ")}fn{t("settings.globalGeneral.cLIBinaryOnPATH", " CLI binary on PATH ")} + {t("settings.globalGeneral.whenEnabledTheDashboardProbesForAGlobally", " When enabled, the dashboard probes for a globally-installed")}{" "} + fn / fusion{t("settings.globalGeneral.cLIBySpawning", " CLI by spawning")}{" "} + <bin> --version{t("settings.globalGeneral.disableThisIfYourLocalDevProcessIs", ". Disable this if your local dev process is the source of truth and you don't want any outdated globally-installed binary executed during the probe. ")}
-

Updates

+

{t("settings.globalGeneral.updates", "Updates")}

- - When enabled, Fusion checks npm for new versions of{" "} - @runfusion/fusion and shows update notices in the CLI and dashboard. - Cadence is governed by the frequency below. - + setForm((f) => ({ ...f, updateCheckEnabled: e.target.checked }))}/>{t("settings.globalGeneral.checkForUpdatesAutomatically", " Check for updates automatically ")} + {t("settings.globalGeneral.whenEnabledFusionChecksNpmForNewVersions", " When enabled, Fusion checks npm for new versions of")}{" "} + @runfusion/fusion{t("settings.globalGeneral.andShowsUpdateNoticesInTheCLIAnd", " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. ")}
- - setForm((f) => ({ + ...f, + updateCheckFrequency: e.target.value as "manual" | "on-startup" | "daily" | "weekly", + }))} disabled={form.updateCheckEnabled === false}> + + + + - - Controls how often the dashboard re-fetches the npm registry. - Use the version + refresh control in the header to trigger an - immediate check at any time. - + {t("settings.globalGeneral.controlsHowOftenTheDashboardReFetchesThe", " Controls how often the dashboard re-fetches the npm registry. Use the version + refresh control in the header to trigger an immediate check at any time. ")}
- - When enabled (default), the dashboard automatically reloads when it - detects a new build version — either from server rebuilds or service - worker updates. Disable this to stay on the current version until you - manually refresh. - + setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))}/>{t("settings.globalGeneral.autoReloadDashboardOnVersionChange", " Auto-reload dashboard on version change ")} + {t("settings.globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen", " When enabled (default), the dashboard automatically reloads when it detects a new build version \u2014 either from server rebuilds or service worker updates. Disable this to stay on the current version until you manually refresh. ")}
- - ); + ); } - export default GlobalGeneralSection; diff --git a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx index 05e682eec8..9ed772173c 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx @@ -1,13 +1,3 @@ -/** - * Global Models section (U9 / KTD-10). - * - * Default + fallback model pickers, thinking-effort selector (only for reasoning - * models), the per-role global model lanes, startup model-sync toggles, and the - * OpenRouter advanced routing/attribution knobs. Model catalog, favorites, and - * the favorite-toggle handlers live in the shell (fetched + persisted there) and - * are relayed as props. The comma-list (de)serializers are reproduced locally as - * pure helpers — identical to the modal's. - */ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { THINKING_LEVELS } from "@fusion/core"; @@ -15,424 +5,271 @@ import type { Settings, ThinkingLevel } from "@fusion/core"; import type { ModelInfo } from "../../../api"; import { CustomModelDropdown } from "../../CustomModelDropdown"; import type { SectionBaseProps, ModelLane } from "./context"; - function toCommaSeparatedInput(values?: string[]): string { - return values?.join(", ") ?? ""; + return values?.join(", ") ?? ""; } - function fromCommaSeparatedInput(value: string): string[] { - return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); + return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); } - export interface GlobalModelsSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - availableModels: ModelInfo[]; - modelsLoading: boolean; - /** Global model lanes (i.e. MODEL_LANES without the `default` lane). */ - globalModelLanes: ModelLane[]; - favoriteProviders: string[]; - favoriteModels: string[]; - onToggleFavorite: (provider: string) => void; - onToggleModelFavorite: (modelId: string) => void; + scopeBanner: ReactNode; + availableModels: ModelInfo[]; + modelsLoading: boolean; + /** Global model lanes (i.e. MODEL_LANES without the `default` lane). */ + globalModelLanes: ModelLane[]; + favoriteProviders: string[]; + favoriteModels: string[]; + onToggleFavorite: (provider: string) => void; + onToggleModelFavorite: (modelId: string) => void; } - -export function GlobalModelsSection({ - scopeBanner, - form, - setForm, - availableModels, - modelsLoading, - globalModelLanes, - favoriteProviders, - favoriteModels, - onToggleFavorite, - onToggleModelFavorite, -}: GlobalModelsSectionProps) { - const { t } = useTranslation("app"); - const selectedValue = - form.defaultProvider && form.defaultModelId - ? `${form.defaultProvider}/${form.defaultModelId}` - : ""; - - return ( - <> +export function GlobalModelsSection({ scopeBanner, form, setForm, availableModels, modelsLoading, globalModelLanes, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, }: GlobalModelsSectionProps) { + const { t } = useTranslation("app"); + const selectedValue = form.defaultProvider && form.defaultModelId + ? `${form.defaultProvider}/${form.defaultModelId}` + : ""; + return (<> {scopeBanner} {/* --- Default Model --- */} -

Default Model

- {modelsLoading ? ( -
{t("settings.models.loadingModels", "Loading available models…")}
- ) : availableModels.length === 0 ? ( -
+

{t("settings.globalModels.defaultModel", "Default Model")}

+ {modelsLoading ? (
{t("settings.models.loadingModels", "Loading available models…")}
) : availableModels.length === 0 ? (
{t("settings.models.noModels", "No models available. Configure authentication first.")} -
- ) : ( - <> +
) : (<>
- - { + + { if (!val) { - setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - defaultProvider: val.slice(0, slashIdx), - defaultModelId: val.slice(slashIdx + 1), - })); + setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined })); } - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={onToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={onToggleModelFavorite} - /> - Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically. -
- -
- - { - if (!val) { - setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - fallbackProvider: val.slice(0, slashIdx), - fallbackModelId: val.slice(slashIdx + 1), - })); - } - }} - placeholder="No fallback" - favoriteProviders={favoriteProviders} - onToggleFavorite={onToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={onToggleModelFavorite} - /> - Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. -
- - )} - {(() => { - const selectedModel = availableModels.find( - (m) => m.provider === form.defaultProvider && m.id === form.defaultModelId, - ); - if (selectedModel && !selectedModel.reasoning) return null; - return ( -
- {/* FNXC:Settings-ThinkingLevel 2026-06-19-14:55: This global selector renders the canonical THINKING_LEVELS list so newly added `xhigh` stays available anywhere the default reasoning effort is configured. */} - - - Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. -
- ); - })()} - - {availableModels.length > 0 && ( - <> -

Model Lanes

-

- Global baseline models for each AI role. Project settings can override these per-project. -

- {globalModelLanes.map((lane) => { - const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined; - const model = form[lane.globalModelKey as keyof Settings] as string | undefined; - const value = provider && model ? `${provider}/${model}` : ""; - - return ( -
- - { - if (!selected) { - setForm((f) => ({ - ...f, - [lane.globalProviderKey]: undefined, - [lane.globalModelKey]: undefined, - })); - return; - } - - const slashIdx = selected.indexOf("/"); + else { + const slashIdx = val.indexOf("/"); setForm((f) => ({ - ...f, - [lane.globalProviderKey]: selected.slice(0, slashIdx), - [lane.globalModelKey]: selected.slice(slashIdx + 1), + ...f, + defaultProvider: val.slice(0, slashIdx), + defaultModelId: val.slice(slashIdx + 1), })); - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={onToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={onToggleModelFavorite} - /> + } + }} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> + {t("settings.globalModels.defaultAIModelUsedForTaskExecutionWhen", "Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically.")} +
+ +
+ + { + if (!val) { + setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined })); + } + else { + const slashIdx = val.indexOf("/"); + setForm((f) => ({ + ...f, + fallbackProvider: val.slice(0, slashIdx), + fallbackModelId: val.slice(slashIdx + 1), + })); + } + }} placeholder={t("settings.globalModels.noFallback", "No fallback")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> + {t("settings.globalModels.usedAutomaticallyIfThePrimaryDefaultModelHits", "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.")} +
+ )} + {(() => { + const selectedModel = availableModels.find((m) => m.provider === form.defaultProvider && m.id === form.defaultModelId); + if (selectedModel && !selectedModel.reasoning) + return null; + return (
+ {/* FNXC:Settings-ThinkingLevel 2026-06-19-14:55: This global selector renders the canonical THINKING_LEVELS list so newly added `xhigh` stays available anywhere the default reasoning effort is configured. */} + + + {t("settings.globalModels.controlsHowMuchReasoningEffortTheAIModel", "Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.")} +
); + })()} + + {availableModels.length > 0 && (<> +

{t("settings.globalModels.modelLanes", "Model Lanes")}

+

{t("settings.globalModels.globalBaselineModelsForEachAIRoleProject", " Global baseline models for each AI role. Project settings can override these per-project. ")}

+ {globalModelLanes.map((lane) => { + const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined; + const model = form[lane.globalModelKey as keyof Settings] as string | undefined; + const value = provider && model ? `${provider}/${model}` : ""; + return (
+ + { + if (!selected) { + setForm((f) => ({ + ...f, + [lane.globalProviderKey]: undefined, + [lane.globalModelKey]: undefined, + })); + return; + } + const slashIdx = selected.indexOf("/"); + setForm((f) => ({ + ...f, + [lane.globalProviderKey]: selected.slice(0, slashIdx), + [lane.globalModelKey]: selected.slice(slashIdx + 1), + })); + }} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> {lane.helperText} -
- ); - })} - - )} + ); + })} + )} {/* --- Startup Model Sync --- */} -

Startup Model Sync

+

{t("settings.globalModels.startupModelSync", "Startup Model Sync")}

- - When enabled, startup fetches the latest available models from the OpenRouter API so - model pickers always include the newest catalog. - + setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpenRouterModelListAtStartup", " Sync OpenRouter model list at startup ")} + {t("settings.globalModels.whenEnabledStartupFetchesTheLatestAvailableModels", " When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. ")}
- - When enabled, startup refreshes models through the local opencode models opencode --refresh - flow and publishes them under the opencode-go provider in model pickers. - + setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpencodeGoModelListAtStartup", " Sync opencode-go model list at startup ")} + {t("settings.globalModels.whenEnabledStartupRefreshesModelsThroughTheLocal", " When enabled, startup refreshes models through the local ")}opencode models opencode --refresh{t("settings.globalModels.flowAndPublishesThemUnderTheOpencodeGo", " flow and publishes them under the opencode-go provider in model pickers. ")}
- OpenRouter advanced + {t("settings.globalModels.openRouterAdvanced", "OpenRouter advanced")}
- - setForm((f) => ({ - ...f, - openrouterAppAttribution: { + + setForm((f) => ({ + ...f, + openrouterAppAttribution: { ...(f.openrouterAppAttribution || {}), referer: e.target.value, - }, - }))} - /> - Leave empty to omit this header. Default: https://runfusion.ai. + }, + }))}/> + {t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultHttps", "Leave empty to omit this header. Default: https://runfusion.ai.")}
- - setForm((f) => ({ - ...f, - openrouterAppAttribution: { + + setForm((f) => ({ + ...f, + openrouterAppAttribution: { ...(f.openrouterAppAttribution || {}), title: e.target.value, - }, - }))} - /> - Leave empty to omit this header. Default: Fusion. + }, + }))}/> + {t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultFusion", "Leave empty to omit this header. Default: Fusion.")}
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ + + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ ...f, openrouterModelFilters: { - ...(f.openrouterModelFilters || {}), - supported_parameters: parsed.length > 0 ? parsed : undefined, + ...(f.openrouterModelFilters || {}), + supported_parameters: parsed.length > 0 ? parsed : undefined, }, - })); - }} - /> - Comma-separated values sent to OpenRouter model sync. + })); + }}/> + {t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync", "Comma-separated values sent to OpenRouter model sync.")}
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ + + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ ...f, openrouterModelFilters: { - ...(f.openrouterModelFilters || {}), - output_modalities: parsed.length > 0 ? parsed : undefined, + ...(f.openrouterModelFilters || {}), + output_modalities: parsed.length > 0 ? parsed : undefined, }, - })); - }} - /> - Comma-separated values sent to OpenRouter model sync. + })); + }}/> + {t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync", "Comma-separated values sent to OpenRouter model sync.")}
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ + + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ ...f, openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - order: parsed.length > 0 ? parsed : undefined, + ...(f.openrouterProviderPreferences || {}), + order: parsed.length > 0 ? parsed : undefined, }, - })); - }} - /> + })); + }}/>
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ + + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ ...f, openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - ignore: parsed.length > 0 ? parsed : undefined, + ...(f.openrouterProviderPreferences || {}), + ignore: parsed.length > 0 ? parsed : undefined, }, - })); - }} - /> + })); + }}/>
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ + + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ ...f, openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - only: parsed.length > 0 ? parsed : undefined, + ...(f.openrouterProviderPreferences || {}), + only: parsed.length > 0 ? parsed : undefined, }, - })); - }} - /> + })); + }}/>
- - { + const value = e.target.value; + setForm((f) => ({ ...f, openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - allow_fallbacks: value === "default" ? undefined : value === "allow", + ...(f.openrouterProviderPreferences || {}), + allow_fallbacks: value === "default" ? undefined : value === "allow", }, - })); - }} - > - - - + })); + }}> + + +
- - { + const value = e.target.value; + setForm((f) => ({ ...f, openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - sort: value === "default" ? undefined : value as "price" | "throughput" | "latency", + ...(f.openrouterProviderPreferences || {}), + sort: value === "default" ? undefined : value as "price" | "throughput" | "latency", }, - })); - }} - > - - - - + })); + }}> + + + +
+ setForm((f) => ({ + ...f, + openrouterProviderPreferences: { + ...(f.openrouterProviderPreferences || {}), + require_parameters: e.target.checked, + }, + }))}/>{t("settings.globalModels.requireParameters", " Require parameters ")}
- - ); + ); } - export default GlobalModelsSection; diff --git a/packages/dashboard/app/components/settings/sections/MemorySection.tsx b/packages/dashboard/app/components/settings/sections/MemorySection.tsx index 061059724f..5a61f08914 100644 --- a/packages/dashboard/app/components/settings/sections/MemorySection.tsx +++ b/packages/dashboard/app/components/settings/sections/MemorySection.tsx @@ -1,354 +1,179 @@ -/** - * Memory section (U9 / KTD-10). - * - * Project-scoped memory configuration: enable toggle, qmd install affordance, - * auto-summarize schedule, dream processing, the retrieval test panel, and the - * file editor with backend-writability gating. All memory fetch/state/handlers - * and the backend-status hook live in the shell (they touch the API, share state - * with the save flow, and the backend hook is enabled only while this section is - * active) and are relayed through a `memory` prop bag — mirroring the - * Authentication/Remote section conventions. The option-label truncation helpers - * are co-located. Keys, conditional gating, and editor wiring are preserved - * verbatim from the original inline JSX. - */ import type { ReactNode } from "react"; import { Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { - MemoryBackendCapabilities, - MemoryBackendStatus, - MemoryFileInfo, - MemoryRetrievalTestResult, -} from "../../../api"; +import type { MemoryBackendCapabilities, MemoryBackendStatus, MemoryFileInfo, MemoryRetrievalTestResult, } from "../../../api"; import { FileEditor } from "../../FileEditor"; import type { SectionBaseProps } from "./context"; - const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72; - function truncateMiddle(value: string, maxChars: number): string { - if (value.length <= maxChars) { - return value; - } - - const visibleChars = Math.max(1, maxChars - 1); - const startChars = Math.ceil(visibleChars / 2); - const endChars = Math.floor(visibleChars / 2); - return `${value.slice(0, startChars)}…${value.slice(value.length - endChars)}`; + if (value.length <= maxChars) { + return value; + } + const visibleChars = Math.max(1, maxChars - 1); + const startChars = Math.ceil(visibleChars / 2); + const endChars = Math.floor(visibleChars / 2); + return `${value.slice(0, startChars)}…${value.slice(value.length - endChars)}`; } - function formatMemoryFileOptionLabel(file: MemoryFileInfo): string { - const fullLabel = `${file.label} — ${file.path}`; - return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS); + const fullLabel = `${file.label} — ${file.path}`; + return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS); } - export interface MemorySectionMemoryProps { - memoryCapabilities: MemoryBackendCapabilities | null; - memoryBackendStatus: MemoryBackendStatus | null; - memoryBackendLoading: boolean; - memoryBackendError: string | null; - memoryFiles: MemoryFileInfo[]; - selectedMemoryPath: string; - setSelectedMemoryPath: (path: string) => void; - memoryContent: string; - setMemoryContent: (content: string) => void; - memoryLoading: boolean; - memoryDirty: boolean; - setMemoryDirty: (dirty: boolean) => void; - memoryTestQuery: string; - setMemoryTestQuery: (query: string) => void; - memoryTestLoading: boolean; - memoryTestResult: MemoryRetrievalTestResult | null; - qmdInstallLoading: boolean; - dreamRunning: boolean; - memoryCompactLoading: boolean; - onInstallQmd: () => void; - onTestMemoryRetrieval: () => void; - onDreamNow: () => void; - onCompactMemory: () => void; - onSaveMemory: () => void; + memoryCapabilities: MemoryBackendCapabilities | null; + memoryBackendStatus: MemoryBackendStatus | null; + memoryBackendLoading: boolean; + memoryBackendError: string | null; + memoryFiles: MemoryFileInfo[]; + selectedMemoryPath: string; + setSelectedMemoryPath: (path: string) => void; + memoryContent: string; + setMemoryContent: (content: string) => void; + memoryLoading: boolean; + memoryDirty: boolean; + setMemoryDirty: (dirty: boolean) => void; + memoryTestQuery: string; + setMemoryTestQuery: (query: string) => void; + memoryTestLoading: boolean; + memoryTestResult: MemoryRetrievalTestResult | null; + qmdInstallLoading: boolean; + dreamRunning: boolean; + memoryCompactLoading: boolean; + onInstallQmd: () => void; + onTestMemoryRetrieval: () => void; + onDreamNow: () => void; + onCompactMemory: () => void; + onSaveMemory: () => void; } - export interface MemorySectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - memory: MemorySectionMemoryProps; + scopeBanner: ReactNode; + memory: MemorySectionMemoryProps; } - export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySectionProps) { - const { t } = useTranslation("app"); - const { - memoryCapabilities: capabilities, - memoryBackendStatus: backendStatus, - memoryBackendLoading: backendLoading, - memoryBackendError: backendError, - memoryFiles, - selectedMemoryPath, - setSelectedMemoryPath, - memoryContent, - setMemoryContent, - memoryLoading, - memoryDirty, - setMemoryDirty, - memoryTestQuery, - setMemoryTestQuery, - memoryTestLoading, - memoryTestResult, - qmdInstallLoading, - dreamRunning, - memoryCompactLoading, - onInstallQmd, - onTestMemoryRetrieval, - onDreamNow, - onCompactMemory, - onSaveMemory, - } = memory; - - // Determine if editing is allowed - const isMemoryEnabled = form.memoryEnabled !== false; - const backendStatusResolved = !backendLoading && backendStatus !== null; - const isBackendWritable = backendStatusResolved ? (capabilities?.writable ?? true) : true; - const isEditingAllowed = isMemoryEnabled && isBackendWritable; - - const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath); - const memoryLayerNames: Record = { - "long-term": "Long-term", - daily: "Daily", - dreams: "Dreams", - }; - - return ( - <> + const { t } = useTranslation("app"); + const { memoryCapabilities: capabilities, memoryBackendStatus: backendStatus, memoryBackendLoading: backendLoading, memoryBackendError: backendError, memoryFiles, selectedMemoryPath, setSelectedMemoryPath, memoryContent, setMemoryContent, memoryLoading, memoryDirty, setMemoryDirty, memoryTestQuery, setMemoryTestQuery, memoryTestLoading, memoryTestResult, qmdInstallLoading, dreamRunning, memoryCompactLoading, onInstallQmd, onTestMemoryRetrieval, onDreamNow, onCompactMemory, onSaveMemory, } = memory; + // Determine if editing is allowed + const isMemoryEnabled = form.memoryEnabled !== false; + const backendStatusResolved = !backendLoading && backendStatus !== null; + const isBackendWritable = backendStatusResolved ? (capabilities?.writable ?? true) : true; + const isEditingAllowed = isMemoryEnabled && isBackendWritable; + const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath); + const memoryLayerNames: Record = { + "long-term": "Long-term", + daily: "Daily", + dreams: "Dreams", + }; + return (<> {scopeBanner} -

Memory

+

{t("settings.memory.memory", "Memory")}

- - Memory lives in .fusion/memory/. Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed. - + {t("settings.memory.memoryLivesIn", " Memory lives in ")}.fusion/memory/{t("settings.memory.agentsSearchWithQmdFirstFallBackTo", ". Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed. ")}
- Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback. + setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))}/>{t("settings.memory.enableMemoryTools", " Enable memory tools ")} + {t("settings.memory.agentsGetMemorySearchMemoryGetAndMemory", "Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.")}
- {backendLoading ? ( -
- Checking memory write access... -
- ) : backendError ? ( -
- Failed to load backend status: {backendError} -
- ) : null} + {backendLoading ? (
+ {t("settings.memory.checkingMemoryWriteAccess", "Checking memory write access...")} +
) : backendError ? (
+ {t("settings.memory.failedToLoadBackendStatus", "Failed to load backend status: ")}{backendError} +
) : null} - {backendStatusResolved && backendStatus.qmdAvailable === false && ( -
- - qmd is not installed. Search will use local files. - Install indexed retrieval: {backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"} + {backendStatusResolved && backendStatus.qmdAvailable === false && (
+ {t("settings.memory.qmdIsNotInstalledSearchWillUseLocal", " qmd is not installed. Search will use local files. Install indexed retrieval: ")}{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"} - -
- )} +
)}
- Automatically compact memory when it exceeds the threshold on a schedule + setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))}/>{t("settings.memory.autoSummarizeMemory", " Auto-Summarize Memory ")} + {t("settings.memory.automaticallyCompactMemoryWhenItExceedsTheThreshold", "Automatically compact memory when it exceeds the threshold on a schedule")}
- {(form.memoryAutoSummarizeEnabled || false) && ( - <> + {(form.memoryAutoSummarizeEnabled || false) && (<>
- - - setForm((f) => ({ - ...f, - memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000, - })) - } - min={1000} - /> - Memory will be compacted when it exceeds this character count + + setForm((f) => ({ + ...f, + memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000, + }))} min={1000}/> + {t("settings.memory.memoryWillBeCompactedWhenItExceedsThis", "Memory will be compacted when it exceeds this character count")}
- - - setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value })) - } - placeholder="0 3 * * *" - /> - Cron expression for auto-summarize schedule (default: daily at 3 AM) + + setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))} placeholder={t("settings.memory.03", "0 3 * * *")}/> + {t("settings.memory.cronExpressionForAutoSummarizeScheduleDefaultDaily", "Cron expression for auto-summarize schedule (default: daily at 3 AM)")}
- - )} + )} -
+
- Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md. + setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))} disabled={!isMemoryEnabled}/>{t("settings.memory.processDreamsFromDailyMemory", " Process dreams from daily memory ")} + {t("settings.memory.turnsDailyNotesIntoDREAMSMdAndPromotes", "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.")}
- {isMemoryEnabled && form.memoryDreamsEnabled === true && ( - <> + {isMemoryEnabled && form.memoryDreamsEnabled === true && (<>
- - - setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value })) - } - /> - Cron expression for dream processing. + + setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))}/> + {t("settings.memory.cronExpressionForDreamProcessing", "Cron expression for dream processing.")}
- - Manually trigger dream processing now. + {t("settings.memory.manuallyTriggerDreamProcessingNow", "Manually trigger dream processing now.")}
- - )} + )}
- - setMemoryTestQuery(e.target.value)} - placeholder="Search memory with qmd" - /> - Runs the same qmd-backed memory_search path agents use. + + setMemoryTestQuery(e.target.value)} placeholder={t("settings.memory.searchMemoryWithQmd", "Search memory with qmd")}/> + {t("settings.memory.runsTheSameQmdBackedMemorySearchPath", "Runs the same qmd-backed memory_search path agents use.")}
-
- {memoryTestResult && ( -
+ {memoryTestResult && (
- {memoryTestResult.results.length} result{memoryTestResult.results.length === 1 ? "" : "s"} - {" "}for "{memoryTestResult.query}" + {memoryTestResult.results.length}{t("settings.memory.result", " result")}{memoryTestResult.results.length === 1 ? "" : "s"} + {" "}{t("settings.memory.for", "for \"")}{memoryTestResult.query}" - - qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"} + {t("settings.memory.qmd", " qmd ")}{memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"} - {memoryTestResult.results.length > 0 ? ( -
    - {memoryTestResult.results.map((result, index) => ( -
  • + {memoryTestResult.results.length > 0 ? (
      + {memoryTestResult.results.map((result, index) => (
    • {result.path}:{result.lineStart}

      {result.snippet}

      -
    • - ))} -
    - ) : ( - No matching memory found. - )} -
- )} + ))} + ) : ({t("settings.memory.noMatchingMemoryFound", "No matching memory found.")})} +
)}
- {!isMemoryEnabled && ( -
- Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled. -
- )} - {isMemoryEnabled && backendStatusResolved && !isBackendWritable && ( -
- Memory is configured with a read-only backend. You can view the file, but saving is disabled. -
- )} + {!isMemoryEnabled && (
{t("settings.memory.memoryIsCurrentlyDisabledYouCanViewThe", " Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled. ")}
)} + {isMemoryEnabled && backendStatusResolved && !isBackendWritable && (
{t("settings.memory.memoryIsConfiguredWithAReadOnlyBackend", " Memory is configured with a read-only backend. You can view the file, but saving is disabled. ")}
)} - {memoryLoading ? ( -
Loading memory…
- ) : ( -
+ {memoryLoading ? (
{t("settings.memory.loadingMemory", "Loading memory\u2026")}
) : (
- - { setSelectedMemoryPath(e.target.value); setMemoryDirty(false); - }} - disabled={memoryDirty} - > - {memoryFiles.map((file) => ( - - ))} + ))} {memoryDirty @@ -356,15 +181,13 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect : "Choose any project memory file to view or edit. Dreams is selected by default."}
- {selectedMemoryFile && ( -
+ {selectedMemoryFile && (
{memoryLayerNames[selectedMemoryFile.layer]} {selectedMemoryFile.path} - {selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()} + {selectedMemoryFile.size.toLocaleString()}{t("settings.memory.bytesUpdated", " bytes \u00B7 updated ")}{new Date(selectedMemoryFile.updatedAt).toLocaleString()} -
- )} +
)}
@@ -374,56 +197,33 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect {!selectedMemoryFile && "Edits the selected memory file."}
- { - setMemoryContent(content); - setMemoryDirty(true); - }} - readOnly={!isEditingAllowed} - filePath={selectedMemoryPath} - /> + { + setMemoryContent(content); + setMemoryDirty(true); + }} readOnly={!isEditingAllowed} filePath={selectedMemoryPath}/>
-
- )} +
)} - {!memoryLoading && ( -
- {memoryDirty - ? "Save or discard edits before compacting this file." - : `Compacts ${selectedMemoryPath} and writes the result back to the same file.`} + ? "Save or discard edits before compacting this file." + : `Compacts ${selectedMemoryPath} and writes the result back to the same file.`} -
- )} +
)} - {memoryDirty && isEditingAllowed && ( -
- -
- )} - {memoryDirty && !isEditingAllowed && ( -
- Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"} -
- )} - - ); +
)} + {memoryDirty && !isEditingAllowed && (
+ {t("settings.memory.cannotSave", "Cannot save: ")}{isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"} +
)} + ); } - export default MemorySection; diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx index 01f9103583..44e30a3040 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -1,740 +1,395 @@ -/** - * Merge section (U9 / KTD-10). - * - * Project-scoped merge policy: auto-merge, AI-merge mode + review passes, test - * mode, merge strategy / integration branch, direct-merge routing, GitHub auth, - * commit attribution, and conflict-resolution strategy. The review/verification - * scope-enforcement knobs moved to the workflow (U4) and render as a redirect - * stub. The integration-branch custom-mode toggle is shell state (it interplays - * with the fetched branch-option list) and relayed as props. Keys, conditional - * visibility, and the legacy-mode warning banner are preserved verbatim from the - * original inline JSX. - */ import type { ReactNode } from "react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Settings } from "@fusion/core"; import { MovedSettingsStub } from "./MovedSettingsStub"; import type { SectionBaseProps } from "./context"; - function resolveMaxAutoMergeRetriesForMergeForm(value: unknown): number { - const configured = Number(value); - return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 3; + const configured = Number(value); + return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 3; } - interface LegacyAutoMergeStampCandidate { - taskId: string; - column: string; - cleared: boolean; + taskId: string; + column: string; + cleared: boolean; } - interface LegacyAutoMergeStampListResponse { - candidates: LegacyAutoMergeStampCandidate[]; - count: number; + candidates: LegacyAutoMergeStampCandidate[]; + count: number; } - interface LegacyAutoMergeStampApplyResponse { - cleared: LegacyAutoMergeStampCandidate[]; - count: number; + cleared: LegacyAutoMergeStampCandidate[]; + count: number; } - async function readLegacyAutoMergeStampResponse(response: Response): Promise { - if (!response.ok) { - throw new Error(await response.text() || "Failed to load legacy auto-merge stamps"); - } - return response.json() as Promise; + if (!response.ok) { + throw new Error(await response.text() || "Failed to load legacy auto-merge stamps"); + } + return response.json() as Promise; } - export interface MergeSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - integrationBranchOptions: string[]; - integrationBranchCustomMode: boolean; - setIntegrationBranchCustomMode: (value: boolean) => void; - onOpenWorkflowSettings?: () => void; + scopeBanner: ReactNode; + integrationBranchOptions: string[]; + integrationBranchCustomMode: boolean; + setIntegrationBranchCustomMode: (value: boolean) => void; + onOpenWorkflowSettings?: () => void; } - -export function MergeSection({ - scopeBanner, - form, - setForm, - integrationBranchOptions, - integrationBranchCustomMode, - setIntegrationBranchCustomMode, - onOpenWorkflowSettings, -}: MergeSectionProps) { - const { t } = useTranslation("app"); - const [legacyStampCandidates, setLegacyStampCandidates] = useState([]); - const [legacyStampLoading, setLegacyStampLoading] = useState(true); - const [legacyStampApplying, setLegacyStampApplying] = useState(false); - const [legacyStampError, setLegacyStampError] = useState(null); - const [legacyStampSuccess, setLegacyStampSuccess] = useState(null); - - const loadLegacyAutoMergeStamps = useCallback(async () => { - setLegacyStampLoading(true); - setLegacyStampError(null); - try { - const data = await readLegacyAutoMergeStampResponse( - await fetch("/api/maintenance/legacy-automerge-stamps"), - ); - setLegacyStampCandidates(Array.isArray(data.candidates) ? data.candidates : []); - } catch (err) { - setLegacyStampError(err instanceof Error ? err.message : "Failed to load legacy auto-merge stamps"); - } finally { - setLegacyStampLoading(false); - } - }, []); - - useEffect(() => { - void loadLegacyAutoMergeStamps(); - }, [loadLegacyAutoMergeStamps]); - - const applyLegacyAutoMergeStampCleanup = async () => { - const confirmed = window.confirm( - "Apply cleanup for legacy auto-merge stamps? This clears only legacy non-override in-review stamps returned by the store and never touches genuine per-task overrides.", - ); - if (!confirmed) return; - setLegacyStampApplying(true); - setLegacyStampError(null); - setLegacyStampSuccess(null); - try { - const response = await fetch("/api/maintenance/legacy-automerge-stamps/apply", { method: "POST" }); - if (!response.ok) { - throw new Error(await response.text() || "Failed to apply legacy auto-merge stamp cleanup"); - } - const data = await response.json() as LegacyAutoMergeStampApplyResponse; - setLegacyStampSuccess(`Cleared ${data.count} legacy auto-merge stamp${data.count === 1 ? "" : "s"}.`); - await loadLegacyAutoMergeStamps(); - } catch (err) { - setLegacyStampError(err instanceof Error ? err.message : "Failed to apply legacy auto-merge stamp cleanup"); - } finally { - setLegacyStampApplying(false); - } - }; - - return ( - <> +export function MergeSection({ scopeBanner, form, setForm, integrationBranchOptions, integrationBranchCustomMode, setIntegrationBranchCustomMode, onOpenWorkflowSettings, }: MergeSectionProps) { + const { t } = useTranslation("app"); + const [legacyStampCandidates, setLegacyStampCandidates] = useState([]); + const [legacyStampLoading, setLegacyStampLoading] = useState(true); + const [legacyStampApplying, setLegacyStampApplying] = useState(false); + const [legacyStampError, setLegacyStampError] = useState(null); + const [legacyStampSuccess, setLegacyStampSuccess] = useState(null); + const loadLegacyAutoMergeStamps = useCallback(async () => { + setLegacyStampLoading(true); + setLegacyStampError(null); + try { + const data = await readLegacyAutoMergeStampResponse(await fetch("/api/maintenance/legacy-automerge-stamps")); + setLegacyStampCandidates(Array.isArray(data.candidates) ? data.candidates : []); + } + catch (err) { + setLegacyStampError(err instanceof Error ? err.message : "Failed to load legacy auto-merge stamps"); + } + finally { + setLegacyStampLoading(false); + } + }, []); + useEffect(() => { + void loadLegacyAutoMergeStamps(); + }, [loadLegacyAutoMergeStamps]); + const applyLegacyAutoMergeStampCleanup = async () => { + const confirmed = window.confirm("Apply cleanup for legacy auto-merge stamps? This clears only legacy non-override in-review stamps returned by the store and never touches genuine per-task overrides."); + if (!confirmed) + return; + setLegacyStampApplying(true); + setLegacyStampError(null); + setLegacyStampSuccess(null); + try { + const response = await fetch("/api/maintenance/legacy-automerge-stamps/apply", { method: "POST" }); + if (!response.ok) { + throw new Error(await response.text() || "Failed to apply legacy auto-merge stamp cleanup"); + } + const data = await response.json() as LegacyAutoMergeStampApplyResponse; + setLegacyStampSuccess(`Cleared ${data.count} legacy auto-merge stamp${data.count === 1 ? "" : "s"}.`); + await loadLegacyAutoMergeStamps(); + } + catch (err) { + setLegacyStampError(err instanceof Error ? err.message : "Failed to apply legacy auto-merge stamp cleanup"); + } + finally { + setLegacyStampApplying(false); + } + }; + return (<> {scopeBanner} -

Merge

+

{t("settings.merge.merge", "Merge")}

+ setForm((f) => ({ ...f, autoMerge: e.target.checked }))}/>{t("settings.merge.autoMergeCompletedTasks", " Auto-merge completed tasks ")}
- More details - When enabled, tasks that pass review are automatically merged into the main branch + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.whenEnabledTasksThatPassReviewAreAutomatically", "When enabled, tasks that pass review are automatically merged into the main branch")}
- + {/* FNXC:AutoMergeRetries 2026-06-17-04:20: Operators need a merge-section control for maxAutoMergeRetries so conflict-heavy projects can tune how many auto-resolution attempts occur before Fusion parks a task for human recovery. Invalid input falls back to 3 to preserve prior behavior. */} - - setForm((f) => ({ - ...f, - maxAutoMergeRetries: e.target.value === "" ? undefined : resolveMaxAutoMergeRetriesForMergeForm(e.target.value), - })) - } - /> - Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3. + setForm((f) => ({ + ...f, + maxAutoMergeRetries: e.target.value === "" ? undefined : resolveMaxAutoMergeRetriesForMergeForm(e.target.value), + }))}/> + {t("settings.merge.positiveIntegerRetryCapForAutoMergeConflict", "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.")}
-
Legacy auto-merge stamp cleanup
- - Finds in-review tasks whose auto-merge value came from the legacy review-entry stamp. - Dry-run is automatic; applying delegates to the store cleanup and preserves genuine - per-task overrides. - - {legacyStampLoading ? ( - Checking for legacy auto-merge stamps… - ) : legacyStampCandidates.length === 0 ? ( - - No legacy auto-merge stamps to clean up. - - ) : ( - <> - {legacyStampCandidates.length} legacy auto-merge stamp{legacyStampCandidates.length === 1 ? "" : "s"} ready to clean up. +
{t("settings.merge.legacyAutoMergeStampCleanup", "Legacy auto-merge stamp cleanup")}
+ {t("settings.merge.findsInReviewTasksWhoseAutoMergeValue", " Finds in-review tasks whose auto-merge value came from the legacy review-entry stamp. Dry-run is automatic; applying delegates to the store cleanup and preserves genuine per-task overrides. ")} + {legacyStampLoading ? ({t("settings.merge.checkingForLegacyAutoMergeStamps", "Checking for legacy auto-merge stamps\u2026")}) : legacyStampCandidates.length === 0 ? ({t("settings.merge.noLegacyAutoMergeStampsToCleanUp", " No legacy auto-merge stamps to clean up. ")}) : (<> + {legacyStampCandidates.length}{t("settings.merge.legacyAutoMergeStamp", " legacy auto-merge stamp")}{legacyStampCandidates.length === 1 ? "" : "s"}{t("settings.merge.readyToCleanUp", " ready to clean up.")}
    - {legacyStampCandidates.map((candidate) => ( -
  • + {legacyStampCandidates.map((candidate) => (
  • {candidate.taskId} — {candidate.column} -
  • - ))} + ))}
- - - )} + )} {legacyStampSuccess ? {legacyStampSuccess} : null} {legacyStampError ? {legacyStampError} : null}
- - setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))}> + +
- More details - - AI mode merges the task branch into an isolated clean-room checkout at the target - branch's tip, has an AI reviewer audit the squash (with corrective retries — - advisory concerns land with a logged warning, an unfixable correctness concern - hard-fails), then fast-forwards the target branch and syncs your local checkout - (AI reconciles a conflicting restore). Each task merges to its own target branch, - or the default integration branch. The legacy merge settings below do not - apply while AI merge is on. + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.aIModeMergesTheTaskBranchIntoAn", " AI mode merges the task branch into an isolated clean-room checkout at the target branch's tip, has an AI reviewer audit the squash (with corrective retries \u2014 advisory concerns land with a logged warning, an unfixable correctness concern hard-fails), then fast-forwards the target branch and syncs your local checkout (AI reconciles a conflicting restore). Each task merges to its own target branch, or the default integration branch. ")}{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}
- {(form.merger?.mode ?? "ai") === "ai" && ( - <> + {(form.merger?.mode ?? "ai") === "ai" && (<>
- - - setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } })) - } - /> - AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model. + + setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))}/> + {t("settings.merge.aICorrectiveRoundsBeforeLandingTheBestResult", "AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model.")}
+ setForm((f) => ({ + ...f, + merger: { ...(f.merger ?? {}), allowDirtyLocalCheckoutSync: e.target.checked }, + }))}/>{t("settings.merge.allowAIMergeToSyncADirtyChecked", " Allow AI merge to sync a dirty checked-out integration branch ")}
- More details - - Dangerous compatibility escape hatch. Leave off unless you explicitly want the legacy - stash → fast-forward → restore behavior when your checked-out integration branch has - unrelated local edits. When off, AI merge blocks before advancing the branch so dirty - project-root edits cannot contaminate a completed merge. - + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.dangerousCompatibilityEscapeHatchLeaveOffUnlessYou", " Dangerous compatibility escape hatch. Leave off unless you explicitly want the legacy stash \u2192 fast-forward \u2192 restore behavior when your checked-out integration branch has unrelated local edits. When off, AI merge blocks before advancing the branch so dirty project-root edits cannot contaminate a completed merge. ")}
- - )} + )}
+ setForm((f) => ({ ...f, testMode: e.target.checked }))}/>{t("settings.merge.enableTestMode", " Enable test mode ")}
- More details - Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost. + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.forcesAllAILanesToUseTheDeterministic", "Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.")}
- +
- - setForm((f) => ({ ...f, mergeStrategy: e.target.value as Settings["mergeStrategy"] }))}> + +
- More details - - Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR. - + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.controlsWhatHappensAfterATaskReachesIn", " Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR. ")}
- + {(() => { - const currentValue = form.integrationBranch ?? ""; - const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue); - const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown); - if (isCustomMode) { - return ( -
- { - const trimmed = e.target.value.trim(); + const currentValue = form.integrationBranch ?? ""; + const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue); + const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown); + if (isCustomMode) { + return (
+ { + const trimmed = e.target.value.trim(); + setForm((f) => ({ + ...f, + integrationBranch: trimmed.length === 0 ? undefined : trimmed, + })); + }} data-testid="integration-branch-custom-input"/> + +
); + } + const CUSTOM = "__fusion-custom__"; + const AUTO = ""; + return ( { - const next = e.target.value; - if (next === CUSTOM) { - setIntegrationBranchCustomMode(true); - return; - } - setForm((f) => ({ - ...f, - integrationBranch: next === AUTO ? undefined : next, - })); - }} - data-testid="integration-branch-select" - > - - {integrationBranchOptions.map((name) => ( - - ))} - - - ); + }} data-testid="integration-branch-select"> + + {integrationBranchOptions.map((name) => ())} + + ); })()}
- More details - - The canonical branch Fusion merges tasks into and uses as the reference for all - ahead/behind / overlap / pre-rebase computations. Leave on auto-detect - to resolve via the standard cascade - (integrationBranch → legacy baseBranch → - origin/HEAD symbolic ref → fallback main). Pick a - local branch from the dropdown — common integration names like main, - master, trunk, and develop are listed - first — or choose Custom… to type a branch that doesn't exist - locally yet. Applies to both direct merges and pull-request mode; individual - tasks can still override via task metadata. - + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.theCanonicalBranchFusionMergesTasksIntoAnd", " The canonical branch Fusion merges tasks into and uses as the reference for all ahead/behind / overlap / pre-rebase computations. Leave on ")}{t("settings.merge.autoDetect", "auto-detect")}{t("settings.merge.toResolveViaTheStandardCascade", " to resolve via the standard cascade (")}integrationBranch{t("settings.merge.legacy", " \u2192 legacy ")}baseBranch → + origin/HEAD{t("settings.merge.symbolicRefFallback", " symbolic ref \u2192 fallback ")}main{t("settings.merge.pickALocalBranchFromTheDropdownCommon", "). Pick a local branch from the dropdown \u2014 common integration names like ")}main, + master, trunk{t("settings.merge.and", ", and ")}develop{t("settings.merge.areListedFirstOrChoose", " are listed first \u2014 or choose ")}{t("settings.merge.custom", "Custom\u2026")}{t("settings.merge.toTypeABranchThatDoesnAposT", " to type a branch that doesn't exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ")}
- {form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && ( - <> + {form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (<>
- - setForm((f) => ({ + ...f, + directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase", + }))}> + + +
- More details - - Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with **Direct Merge Commit Strategy:** auto|always-squash|always-rebase. + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.autoKeepsTodayAposSSquashBehaviorFor", " Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with ")}**Direct Merge Commit Strategy:** auto|always-squash|always-rebase.
- - setForm((f) => ({ + ...f, + mergeIntegrationWorktree: e.target.value as Settings["mergeIntegrationWorktree"], + }))}> + + - - Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk. - - {(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && ( -
- Legacy integration-branch mode.{" "} - Auto-merge will run rebase, conflict resolution, and squash commits inside the - project root (the user's checked-out integration-branch worktree) instead of - the task worktree. Fusion assumes that directory is already on the integration - branch and clean; if it isn't, merges may fail or touch the user's working - tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless - you have a specific reason to opt in (FN-5348). -
- )} + {t("settings.merge.autoMergeRunsInTheTaskWorktreeBy", " Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk. ")} + {(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (
+ {t("settings.merge.legacyIntegrationBranchMode", "Legacy integration-branch mode.")}{" "}{t("settings.merge.autoMergeWillRunRebaseConflictResolutionAnd", " Auto-merge will run rebase, conflict resolution, and squash commits inside the project root (the user's checked-out integration-branch worktree) instead of the task worktree. Fusion assumes that directory is already on the integration branch and clean; if it isn't, merges may fail or touch the user's working tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless you have a specific reason to opt in (FN-5348). ")}
)}
- - setForm((f) => ({ + ...f, + mergeAdvanceAutoSync: e.target.value as "off" | "ff-only" | "stash-and-ff", + }))} data-testid="merge-advance-auto-sync-select"> + + +
- More details - - After Fusion advances the integration branch ref, the merger can auto-sync other - worktrees still checked out on that branch (typically your project-root - checkout). Stash + fast-forward snapshots real local edits as a patch - against the previous tip, snaps the worktree to the new tip, then reapplies the - patch — untracked files that collide with newly-tracked paths are left in a temp - dir for manual recovery. Fast-forward only snaps cleanly when the - worktree has no edits and skips otherwise. Off is the legacy - behavior: git status in your project root will show the new commits - inverted as "staged changes" until you pull manually. Only applies to direct - merges. - + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.afterFusionAdvancesTheIntegrationBranchRefThe", " After Fusion advances the integration branch ref, the merger can auto-sync other worktrees still checked out on that branch (typically your project-root checkout). ")}Stash + fast-forward{t("settings.merge.snapshotsRealLocalEditsAsAPatchAgainst", " snapshots real local edits as a patch against the previous tip, snaps the worktree to the new tip, then reapplies the patch \u2014 untracked files that collide with newly-tracked paths are left in a temp dir for manual recovery. ")}Fast-forward only{t("settings.merge.snapsCleanlyWhenTheWorktreeHasNoEdits", " snaps cleanly when the worktree has no edits and skips otherwise. ")}Off{t("settings.merge.isTheLegacyBehavior", " is the legacy behavior: ")}git status{t("settings.merge.inYourProjectRootWillShowTheNew", " in your project root will show the new commits inverted as "staged changes" until you pull manually. Only applies to direct merges. ")}
- - )} -

GitHub Authentication

+ )} +

{t("settings.merge.gitHubAuthentication", "GitHub Authentication")}

- - setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))}> + +
- {(form.githubAuthMode ?? "gh-cli") === "token" && ( -
- - - setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined })) - } - /> -
- )} + {(form.githubAuthMode ?? "gh-cli") === "token" && (
+ + setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))}/> +
)}
+ setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))}/>{t("settings.merge.includeTaskIDInCommitScope", " Include task ID in commit scope ")}
- More details - When disabled, merge commit messages omit the task ID from the scope (e.g. feat: ... instead of feat(KB-001): ...) + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.whenDisabledMergeCommitMessagesOmitTheTask", "When disabled, merge commit messages omit the task ID from the scope (e.g. ")}feat: ...{t("settings.merge.insteadOf", " instead of ")}feat(KB-001): ...)
+ setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))}/>{t("settings.merge.addFusionAsCoAuthorOnCommits", " Add Fusion as co-author on commits ")}
- More details - - When enabled, commits made by Fusion keep your git identity as the - primary author and append a Co-authored-by trailer crediting - Fusion (recognized by GitHub for shared attribution). - + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.whenEnabledCommitsMadeByFusionKeepYour", " When enabled, commits made by Fusion keep your git identity as the primary author and append a ")}Co-authored-by{t("settings.merge.trailerCreditingFusionRecognizedByGitHubForShared", " trailer crediting Fusion (recognized by GitHub for shared attribution). ")}
- {form.commitAuthorEnabled !== false && ( - <> + {form.commitAuthorEnabled !== false && (<>
- - - setForm((f) => ({ - ...f, - commitAuthorName: e.target.value || undefined, - })) - } - /> - Name used in the Co-authored-by trailer + + setForm((f) => ({ + ...f, + commitAuthorName: e.target.value || undefined, + }))}/> + {t("settings.merge.nameUsedInThe", "Name used in the ")}Co-authored-by{t("settings.merge.trailer", " trailer")}
- - - setForm((f) => ({ - ...f, - commitAuthorEmail: e.target.value || undefined, - })) - } - /> - Email used in the Co-authored-by trailer + + setForm((f) => ({ + ...f, + commitAuthorEmail: e.target.value || undefined, + }))}/> + {t("settings.merge.emailUsedInThe", "Email used in the ")}Co-authored-by{t("settings.merge.trailer", " trailer")}
- - )} + )}
+ setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))}/>{t("settings.merge.autoResolveConflictsInLockFilesAndGenerated", " Auto-resolve conflicts in lock files and generated files ")}
- More details - When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review. + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.whenEnabledLockFilesPackageLockJsonPnpm", "When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review.")}
- {(form.merger?.mode ?? "ai") !== "ai" && ( - <> + {(form.merger?.mode ?? "ai") !== "ai" && (<>
+ setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))}/>{t("settings.merge.smartConflictResolution", " Smart conflict resolution ")}
- More details - When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review. + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.whenEnabledLockFilesPackageLockJsonPnpm2", "When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.")}
- - setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))}> + + + +
- More details - - Both Smart options start with a best-effort git fetch + fast-forward of local main from origin (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the final fallback: + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.both", " Both ")}{t("settings.merge.smart", "Smart")}{t("settings.merge.optionsStartWithABestEffort", " options start with a best-effort ")}git fetch{t("settings.merge.fastForwardOfLocalMainFrom", " + fast-forward of local main from ")}origin{t("settings.merge.soAFreshlyPushedSiblingCommitDoesntGet", " (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the ")}{t("settings.merge.finalFallback", "final fallback")}: {" "} - Smart, prefer main uses -X ours so main wins — protects just-merged sibling work and is the new default. - {" "} - Smart, prefer task uses -X theirs so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression). - {" "} - AI only retries the AI agent rather than auto-picking a side. - {" "} - Abort stops after the first AI attempt and waits for a human. - {" "} - Legacy "smart" and "prefer-main" values from older settings are migrated automatically. + {t("settings.merge.smartPreferMain", "Smart, prefer main")}{t("settings.merge.uses", " uses ")}-X ours{t("settings.merge.soMainWinsProtectsJustMergedSiblingWork", " so main wins \u2014 protects just-merged sibling work and is the new default. ")}{" "} + {t("settings.merge.smartPreferTask", "Smart, prefer task")}{t("settings.merge.uses", " uses ")}-X theirs{t("settings.merge.soTheTaskBranchWinsFastButCan", " so the task branch wins \u2014 fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression). ")}{" "} + {t("settings.merge.aIOnly", "AI only")}{t("settings.merge.retriesTheAIAgentRatherThanAutoPicking", " retries the AI agent rather than auto-picking a side. ")}{" "} + {t("settings.merge.abort", "Abort")}{t("settings.merge.stopsAfterTheFirstAIAttemptAndWaits", " stops after the first AI attempt and waits for a human. ")}{" "} + {t("settings.merge.legacy2", "Legacy ")}"smart"{t("settings.merge.and2", " and ")}"prefer-main"{t("settings.merge.valuesFromOlderSettingsAreMigratedAutomatically", " values from older settings are migrated automatically.")}
- - setForm((f) => ({ + ...f, + mergeStrategyOverlapBehavior: e.target.value as "flip-to-prefer-branch" | "warn-only" | "ignore", + }))}> + + + - - When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. - + {t("settings.merge.whenUsingSmartPreferMainAutomaticallyPreferThe", " When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. ")}
- - setForm((f) => ({ + ...f, + postMergeAuditMode: e.target.value as "block" | "warn" | "off", + }))}> + + + - - Controls the post-merge audit gate. Warn (default) logs findings but auto-completes the merge. Block is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. Off skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. - + {t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}{t("settings.merge.warn", "Warn")}{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}{t("settings.merge.block", "Block")}{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}{t("settings.merge.off", "Off")}{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. ")}
- - )} + )}
+ setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))}/>{t("settings.merge.pushToRemoteAfterMerge", " Push to remote after merge ")}
- More details - When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed. + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.whenEnabledTheMergedResultIsAutomaticallyPushed", "When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed.")}
- {form.pushAfterMerge && ( -
- - - setForm((f) => ({ ...f, pushRemote: e.target.value || undefined })) - } - /> + {form.pushAfterMerge && (
+ + setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))}/>
- More details - Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin". + {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.gitRemoteToPushToEGOrigin", "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".")}
-
- )} - - ); +
)} + ); } - export default MergeSection; diff --git a/packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx b/packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx index 136aa876dd..95fbd89edd 100644 --- a/packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx +++ b/packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx @@ -1,88 +1,62 @@ -/** - * Node Routing section (U9 / KTD-10). - * - * Project-scoped execution-node default + unavailable-node policy. The node list - * is fetched in the shell (shared with other surfaces) and passed down. Keys, - * node-status rendering, and the inline status label helper are preserved - * verbatim from the original inline JSX. - */ import type { ReactNode } from "react"; import type { NodeInfo } from "../../../api"; import { NodeHealthDot } from "../../NodeHealthDot"; import type { SettingsFormState, SetSettingsForm } from "./context"; - -function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string { - if (status === "online") return "Online"; - if (status === "connecting") return "Connecting"; - if (status === "error") return "Error"; - return "Offline"; +import { useTranslation } from "react-i18next"; +function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error", t: ReturnType>["t"]): string { + if (status === "online") + return t("settings.nodeRouting.statusOnline", "Online"); + if (status === "connecting") + return t("settings.nodeRouting.statusConnecting", "Connecting"); + if (status === "error") + return t("settings.nodeRouting.statusError", "Error"); + return t("settings.nodeRouting.statusOffline", "Offline"); } - export interface NodeRoutingSectionProps { - scopeBanner: ReactNode; - form: SettingsFormState; - setForm: SetSettingsForm; - nodes: NodeInfo[]; + scopeBanner: ReactNode; + form: SettingsFormState; + setForm: SetSettingsForm; + nodes: NodeInfo[]; } - export function NodeRoutingSection({ scopeBanner, form, setForm, nodes }: NodeRoutingSectionProps) { - return ( - <> + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

Node Routing

-

Configure how tasks are routed to execution nodes.

-

These settings apply at the project level.

+

{t("settings.nodeRouting.nodeRouting", "Node Routing")}

+

{t("settings.nodeRouting.configureHowTasksAreRoutedToExecutionNodes", "Configure how tasks are routed to execution nodes.")}

+

{t("settings.nodeRouting.theseSettingsApplyAtTheProjectLevel", "These settings apply at the project level.")}

- - { const val = e.target.value; setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState)); - }} - > - - {nodes.map((node) => ( - - ))} + }}> + + {nodes.map((node) => ())} {(() => { - const selectedNode = nodes.find((node) => node.id === form.defaultNodeId); - if (!selectedNode) return null; - return ( -
- Selected node: - -
- ); + const selectedNode = nodes.find((node) => node.id === form.defaultNodeId); + if (!selectedNode) + return null; + return (
+ {t("settings.nodeRouting.selectedNode", "Selected node:")} + +
); })()} - Used when a task has no node override. Node status is shown for safer routing selection. + {t("settings.nodeRouting.usedWhenATaskHasNoNodeOverride", "Used when a task has no node override. Node status is shown for safer routing selection.")}
- - setForm((f) => ({ + ...f, + unavailableNodePolicy: e.target.value as "block" | "fallback-local", + } as SettingsFormState))}> + +
- - ); + ); } - export default NodeRoutingSection; diff --git a/packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx b/packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx index 20ea42c4df..0d015edafd 100644 --- a/packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx +++ b/packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx @@ -1,101 +1,52 @@ -/** - * Node Sync section (U9 / KTD-10). - * - * Cross-node settings synchronization toggles. Preserves the existing - * "Workflow settings are not synced across nodes yet" informational note - * (KTD-8) verbatim, including its i18n key. - */ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { SectionBaseProps } from "./context"; - export interface NodeSyncSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; + scopeBanner: ReactNode; } - export function NodeSyncSection({ scopeBanner, form, setForm }: NodeSyncSectionProps) { - const { t } = useTranslation("app"); - return ( - <> + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

Node Sync

+

{t("settings.nodeSync.nodeSync", "Node Sync")}

- Automatically synchronize settings between this node and connected remote nodes + setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))}/>{t("settings.nodeSync.enableAutomaticSettingsSync", " Enable automatic settings sync ")} + {t("settings.nodeSync.automaticallySynchronizeSettingsBetweenThisNodeAndConnected", "Automatically synchronize settings between this node and connected remote nodes")}
- {form.settingsSyncEnabled && ( - <> + {form.settingsSyncEnabled && (<>
- Include API keys and OAuth tokens in sync operations + setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))}/>{t("settings.nodeSync.syncModelAuthCredentials", " Sync model auth credentials ")} + {t("settings.nodeSync.includeAPIKeysAndOAuthTokensInSync", "Include API keys and OAuth tokens in sync operations")}
- - setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))}> + + + +
- - setForm((f) => ({ + ...f, + settingsSyncConflictResolution: e.target.value as "last-write-wins" | "always-ask" | "keep-local" | "keep-remote", + }))}> + + + +
- - )} + )} {/* KTD-8: workflow settings are not yet part of the cross-node sync - channel. Non-dismissible, informational only, no action affordance. */} + channel. Non-dismissible, informational only, no action affordance. */}

- {t( - "settings.nodeSync.workflowSettingsNotSynced", - "Workflow settings are not synced across nodes yet.", - )} + {t("settings.nodeSync.workflowSettingsNotSynced", "Workflow settings are not synced across nodes yet.")}

- - ); + ); } - export default NodeSyncSection; diff --git a/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx b/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx index 7804899f33..2c296dd33a 100644 --- a/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx @@ -1,408 +1,246 @@ -/** - * Notifications section (U9 / KTD-10). - * - * Failure-notification policy plus the ntfy and webhook provider cards, - * including per-event toggles and the "test notification" affordances. The - * test-send handler and its loading/result state live in the shell (they touch - * the API and toast); this section relays them as props. Behavior, keys, and - * validation regexes are preserved verbatim from the original inline JSX. - */ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { NtfyNotificationEvent } from "@fusion/core"; import type { SectionBaseProps } from "./context"; - /** Default event set used when a provider has no explicit `*Events` override. */ export const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [ - "in-review", - "merged", - "failed", - "awaiting-approval", - "awaiting-user-review", - "planning-awaiting-input", - "gridlock", - "fallback-used", - "memory-dreams-processed", - "message:agent-to-user", - "message:agent-to-agent", - "message:room", - "oauth-token-expired", + "in-review", + "merged", + "failed", + "awaiting-approval", + "awaiting-user-review", + "planning-awaiting-input", + "gridlock", + "fallback-used", + "memory-dreams-processed", + "message:agent-to-user", + "message:agent-to-agent", + "message:room", + "oauth-token-expired", ]; - export const NOTIFICATION_EVENT_OPTIONS: Array<{ - event: NtfyNotificationEvent; - label: string; - description: string; + event: NtfyNotificationEvent; + label: string; + description: string; }> = [ - { event: "in-review", label: "Task completed (in-review)", description: "When a task moves to In Review (ready for review)" }, - { event: "merged", label: "Task merged", description: "When a task is successfully merged to main" }, - { event: "failed", label: "Task failed", description: "When a task fails during execution (high priority)" }, - { event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" }, - { event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" }, - { event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" }, - { event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" }, - { event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" }, - { event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" }, - { event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" }, - { event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" }, - { event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" }, - { event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" }, - { event: "oauth-token-expired", label: "OAuth token expired", description: "Notify when a provider OAuth token (Codex, Claude, etc.) expires." }, + { event: "in-review", label: "Task completed (in-review)", description: "When a task moves to In Review (ready for review)" }, + { event: "merged", label: "Task merged", description: "When a task is successfully merged to main" }, + { event: "failed", label: "Task failed", description: "When a task fails during execution (high priority)" }, + { event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" }, + { event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" }, + { event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" }, + { event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" }, + { event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" }, + { event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" }, + { event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" }, + { event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" }, + { event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" }, + { event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" }, + { event: "oauth-token-expired", label: "OAuth token expired", description: "Notify when a provider OAuth token (Codex, Claude, etc.) expires." }, ]; - export type TestNotificationProvider = "ntfy" | "webhook" | "ntfy-message" | "ntfy-room"; - export interface NotificationsSectionProps extends SectionBaseProps { - scopeBanner: ReactNode; - testNotificationLoading: Record; - testNotificationResult: Record; - onTestProviderNotification: (provider: TestNotificationProvider) => void; + scopeBanner: ReactNode; + testNotificationLoading: Record; + testNotificationResult: Record; + onTestProviderNotification: (provider: TestNotificationProvider) => void; } - -export function NotificationsSection({ - scopeBanner, - form, - setForm, - testNotificationLoading, - testNotificationResult, - onTestProviderNotification, -}: NotificationsSectionProps) { - const { t } = useTranslation("app"); - return ( - <> +export function NotificationsSection({ scopeBanner, form, setForm, testNotificationLoading, testNotificationResult, onTestProviderNotification, }: NotificationsSectionProps) { + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

Notifications

+

{t("settings.notifications.notifications", "Notifications")}

- - { + const value = e.target.value as "sticky-only" | "all" | "terminal-only"; + setForm((f) => ({ ...f, failureNotificationMode: value })); + }}> + + + - Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts. + {t("settings.notifications.stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits", "Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.")}
- - { - const parsed = Number(e.target.value); - setForm((f) => ({ + + { + const parsed = Number(e.target.value); + setForm((f) => ({ ...f, failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0, - })); - }} - /> - - How long a failure must persist before a push notification is sent. 0 = notify immediately. - + })); + }}/> + {t("settings.notifications.howLongAFailureMustPersistBeforeA", " How long a failure must persist before a push notification is sent. 0 = notify immediately. ")}
- ntfy + {t("settings.notifications.ntfy", "ntfy")} + setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))}/>{t("settings.notifications.enable", " Enable ")}
- {form.ntfyEnabled && ( -
+ {form.ntfyEnabled && (
- - { - const val = e.target.value; - setForm((f) => ({ ...f, ntfyTopic: val || undefined })); - }} - /> - - Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "} - - Learn more about ntfy.sh - + + { + const val = e.target.value; + setForm((f) => ({ ...f, ntfyTopic: val || undefined })); + }}/> + {t("settings.notifications.yourNtfyShTopicName164Alphanumeric", " Your ntfy.sh topic name (1\u201364 alphanumeric/hyphen/underscore characters).")}{" "} + {t("settings.notifications.learnMoreAboutNtfySh", " Learn more about ntfy.sh ")} - {form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && ( - - Topic must be 1–64 alphanumeric, hyphen, or underscore characters - - )} + {form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && ({t("settings.notifications.topicMustBe164AlphanumericHyphenOr", " Topic must be 1\u201364 alphanumeric, hyphen, or underscore characters ")})}
- Advanced + {t("settings.notifications.advanced", "Advanced")}
- - { - const value = e.target.value; - setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined })); - }} - /> - - Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. - - - { - const value = e.target.value; - setForm((f) => ({ ...f, ntfyAccessToken: value || undefined })); - }} - /> - - Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. - + + { + const value = e.target.value; + setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined })); + }}/> + {t("settings.notifications.leaveBlankToKeepTheDefaultServerHttps", " Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. ")} + + { + const value = e.target.value; + setForm((f) => ({ ...f, ntfyAccessToken: value || undefined })); + }}/> + {t("settings.notifications.leaveBlankToPublishWithoutAuthenticationWhenSet", " Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. ")}
- +
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { - const checked = form.ntfyEvents?.includes(event) ?? true; - return ( -
+ const checked = form.ntfyEvents?.includes(event) ?? true; + return (
{description} -
- ); - })} +
); + })}
- - { - const val = e.target.value; - setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined })); - }} - /> - - Base URL for deep links in notifications. When set, clicking a notification - opens the dashboard directly to the task. - - {form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && ( - - Must be a valid URL starting with http:// or https:// - - )} + + { + const val = e.target.value; + setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined })); + }}/> + {t("settings.notifications.baseURLForDeepLinksInNotificationsWhen", " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. ")} + {form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && ({t("settings.notifications.mustBeAValidURLStartingWithHttp", " Must be a valid URL starting with http:// or https:// ")})}
- - -
- {(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && ( -
- {testNotificationResult["ntfy"] && ( - - General: {testNotificationResult["ntfy"].message} - - )} - {testNotificationResult["ntfy-message"] && ( - - Message inbox: {testNotificationResult["ntfy-message"].message} - - )} - {testNotificationResult["ntfy-room"] && ( - - Room reply: {testNotificationResult["ntfy-room"].message} - - )} -
- )} -
- )} + {(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
+ {testNotificationResult["ntfy"] && ({t("settings.notifications.general", " General: ")}{testNotificationResult["ntfy"].message} + )} + {testNotificationResult["ntfy-message"] && ({t("settings.notifications.messageInbox", " Message inbox: ")}{testNotificationResult["ntfy-message"].message} + )} + {testNotificationResult["ntfy-room"] && ({t("settings.notifications.roomReply", " Room reply: ")}{testNotificationResult["ntfy-room"].message} + )} +
)} +
)}
- Webhook + {t("settings.notifications.webhook", "Webhook")} + setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))}/>{t("settings.notifications.webhookNotifications", " Webhook notifications ")}
- {form.webhookEnabled && ( -
+ {form.webhookEnabled && (
- - { - const val = e.target.value; - setForm((f) => ({ ...f, webhookUrl: val || undefined })); - }} - /> + + { + const val = e.target.value; + setForm((f) => ({ ...f, webhookUrl: val || undefined })); + }}/>
- - { + const val = e.target.value as "slack" | "discord" | "generic"; + setForm((f) => ({ ...f, webhookFormat: val })); + }}> + + +
- +
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { - const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS]; - const checked = currentEvents.includes(event); - return ( -
+ const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS]; + const checked = currentEvents.includes(event); + return (
{description} -
- ); - })} +
); + })}
-
- {testNotificationResult["webhook"] && ( -
+ {testNotificationResult["webhook"] && (
{testNotificationResult["webhook"].message} -
- )} -
- )} +
)} +
)}
- - ); + ); } - export default NotificationsSection; diff --git a/packages/dashboard/app/components/settings/sections/PluginsSection.tsx b/packages/dashboard/app/components/settings/sections/PluginsSection.tsx index de1887ad75..f1e91596e6 100644 --- a/packages/dashboard/app/components/settings/sections/PluginsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/PluginsSection.tsx @@ -1,98 +1,39 @@ -/** - * Plugins section (U9 / KTD-10). - * - * Project-scoped plugin manager with the Fusion-plugins / Pi-extensions subsection - * tab pair. The active-subsection state lives in the shell (its initial value is - * derived from the modal's entry section) and is relayed as props. The lazy - * managers and the plugin slot are co-located here. Markup, ARIA wiring, and the - * lazy-load Suspense boundaries are preserved verbatim from the original inline - * JSX. - */ import { lazy, Suspense, type ReactNode } from "react"; import { PluginSlot } from "../../PluginSlot"; import type { ToastType } from "../../../hooks/useToast"; - +import { useTranslation } from "react-i18next"; const PluginManager = lazy(() => import("../../PluginManager").then((m) => ({ default: m.PluginManager }))); const PiExtensionsManager = lazy(() => import("../../PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager }))); - export type PluginsSubsectionId = "fusion-plugins" | "pi-extensions"; - export interface PluginsSectionProps { - scopeBanner: ReactNode; - projectId?: string; - addToast: (message: string, type?: ToastType) => void; - activePluginsSubsection: PluginsSubsectionId; - setActivePluginsSubsection: (id: PluginsSubsectionId) => void; + scopeBanner: ReactNode; + projectId?: string; + addToast: (message: string, type?: ToastType) => void; + activePluginsSubsection: PluginsSubsectionId; + setActivePluginsSubsection: (id: PluginsSubsectionId) => void; } - -export function PluginsSection({ - scopeBanner, - projectId, - addToast, - activePluginsSubsection, - setActivePluginsSubsection, -}: PluginsSectionProps) { - return ( - <> +export function PluginsSection({ scopeBanner, projectId, addToast, activePluginsSubsection, setActivePluginsSubsection, }: PluginsSectionProps) { + const { t } = useTranslation("app"); + return (<> {scopeBanner} -

Plugins

-
- - +

{t("settings.plugins.plugins", "Plugins")}

+
+ +
-