FN-6771: localize settings section copy

Localize dashboard settings section text and restore i18n lint coverage for those files.

- Replace hardcoded settings section labels, help text, placeholders, and status copy with i18n lookups.
- Add settings translation keys across supported app and common locale catalogs and refresh generated resource types.
- Remove the settings sections from the i18n lint deferral list and document that coverage requirement.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-6771-localize-settings-sections.md   |   5 +
 docs/i18n-contributing.md                          |   3 +-
 i18next.config.ts                                  |  26 +-
 .../settings/sections/AgentPermissionsSection.tsx  |  55 +-
 .../settings/sections/AppearanceSection.tsx        |  91 +-
 .../settings/sections/AuthenticationSection.tsx    | 457 +++-------
 .../settings/sections/BackupsSection.tsx           | 229 ++---
 .../settings/sections/CommandsSection.tsx          |  49 +-
 .../settings/sections/ExperimentalSection.tsx      | 109 +--
 .../settings/sections/GeneralSection.tsx           | 487 ++++-------
 .../settings/sections/GlobalGeneralSection.tsx     | 183 +---
 .../settings/sections/GlobalModelsSection.tsx      | 525 ++++-------
 .../components/settings/sections/MemorySection.tsx | 458 +++-------
 .../components/settings/sections/MergeSection.tsx  | 863 ++++++------------
 .../settings/sections/NodeRoutingSection.tsx       | 108 +--
 .../settings/sections/NodeSyncSection.tsx          | 105 +--
 .../settings/sections/NotificationsSection.tsx     | 484 ++++------
 .../settings/sections/PluginsSection.tsx           | 105 +--
 .../settings/sections/ProjectModelsSection.tsx     | 971 ++++++++-------------
 .../components/settings/sections/RemoteSection.tsx | 560 +++++-------
 .../settings/sections/ResearchGlobalSection.tsx    | 314 ++-----
 .../settings/sections/ResearchProjectSection.tsx   | 226 ++---
 .../settings/sections/RuntimesSections.tsx         |  40 +-
 .../settings/sections/ScheduledEvalsSection.tsx    | 169 ++--
 .../settings/sections/SchedulingSection.tsx        | 381 ++------
 .../settings/sections/WorktreesSection.tsx         | 330 ++-----
 packages/i18n/locales/en/app.json                  | 698 ++++++++++++++-
 packages/i18n/locales/en/common.json               |   8 +
 packages/i18n/locales/es/app.json                  | 698 ++++++++++++++-
 packages/i18n/locales/es/common.json               |   8 +
 packages/i18n/locales/fr/app.json                  | 698 ++++++++++++++-
 packages/i18n/locales/fr/common.json               |   8 +
 packages/i18n/locales/ko/app.json                  | 698 ++++++++++++++-
 packages/i18n/locales/ko/common.json               |   8 +
 packages/i18n/locales/zh-CN/app.json               | 698 ++++++++++++++-
 packages/i18n/locales/zh-CN/common.json            |   8 +
 packages/i18n/locales/zh-TW/app.json               | 698 ++++++++++++++-
 packages/i18n/locales/zh-TW/common.json            |   8 +
 packages/i18n/src/resources.d.ts                   | 706 ++++++++++++++-
 39 files changed, 7178 insertions(+), 5097 deletions(-)

Fusion-Task-Id: FN-6771
Fusion-Task-Lineage: cdf9b36f-414e-4084-b661-b578a4c27314
This commit is contained in:
gsxdsm
2026-06-20 08:57:45 -07:00
parent 52924ba23d
commit eca96fbd0b
39 changed files with 7179 additions and 5098 deletions

View File

@@ -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.

View File

@@ -52,7 +52,8 @@ content in `<kbd>`, instead of hiding source directories.
Any remaining user-facing copy must be localized with `t()` / `<Trans>` 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

View File

@@ -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;
/**

View File

@@ -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>): 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}
<h4 className="settings-section-heading">Agent Permissions</h4>
<h4 className="settings-section-heading">{t("settings.agentPermissions.agentPermissions", "Agent Permissions")}</h4>
<div className="form-group">
<small className="settings-muted">Per-agent settings override project defaults. Each category controls a separate approval gate.</small>
<small className="settings-muted">{t("settings.agentPermissions.perAgentSettingsOverrideProjectDefaultsEachCategory", "Per-agent settings override project defaults. Each category controls a separate approval gate.")}</small>
</div>
<AgentPermissionPolicyEditor
mode="project-default"
value={form.defaultAgentPermissionPolicy ? { presetId: "custom", rules: toCompleteAgentPermissionRules(form.defaultAgentPermissionPolicy.rules) } : { presetId: "custom", rules: toCompleteAgentPermissionRules() }}
onChange={(next) =>
setForm((f) => ({
<AgentPermissionPolicyEditor mode="project-default" value={form.defaultAgentPermissionPolicy ? { presetId: "custom", rules: toCompleteAgentPermissionRules(form.defaultAgentPermissionPolicy.rules) } : { presetId: "custom", rules: toCompleteAgentPermissionRules() }} onChange={(next) => setForm((f) => ({
...f,
defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules) },
}))
}
/>
}))}/>
<h4 className="settings-section-heading">Agent Provisioning Approvals</h4>
<h4 className="settings-section-heading">{t("settings.agentPermissions.agentProvisioningApprovals", "Agent Provisioning Approvals")}</h4>
<div className="form-group">
<small className="settings-muted">
Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete).
</small>
<small className="settings-muted">{t("settings.agentPermissions.configureProjectLevelApprovalBehaviorForDurableProvisioning", " Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete). ")}</small>
</div>
<AgentProvisioningPolicyEditor
value={form.agentProvisioning}
onChange={(next) => setForm((f) => ({ ...f, agentProvisioning: next }))}
/>
</>
);
<AgentProvisioningPolicyEditor value={form.agentProvisioning} onChange={(next) => setForm((f) => ({ ...f, agentProvisioning: next }))}/>
</>);
}
export default AgentPermissionsSection;

View File

@@ -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}
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
<ThemeSelector
themeMode={themeMode}
colorTheme={colorTheme}
dashboardFontScalePct={dashboardFontScalePct}
onThemeModeChange={(mode) => {
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);
}}
/>
<ThemeSelector themeMode={themeMode} colorTheme={colorTheme} dashboardFontScalePct={dashboardFontScalePct} onThemeModeChange={(mode) => {
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);
}}/>
<LanguageSelector />
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={sessionBannersHidden}
onChange={(e) => setSessionBannersHidden(e.target.checked)}
/>
<span>Hide AI session notification banners</span>
<input type="checkbox" checked={sessionBannersHidden} onChange={(e) => setSessionBannersHidden(e.target.checked)}/>
<span>{t("settings.appearance.hideAISessionNotificationBanners", "Hide AI session notification banners")}</span>
</label>
<small className="form-text text-muted">
Suppress the &ldquo;needs your input&rdquo; banner that appears when AI sessions are awaiting input or have failed.
</small>
<small className="form-text text-muted">{t("settings.appearance.suppressTheLdquoNeedsYourInputRdquoBanner", " Suppress the &ldquo;needs your input&rdquo; banner that appears when AI sessions are awaiting input or have failed. ")}</small>
</div>
</>
);
</>);
}
export default AppearanceSection;

View File

@@ -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<string, string>;
setApiKeyInputs: Dispatch<SetStateAction<Record<string, string>>>;
apiKeyErrors: Record<string, string>;
opencodeApiKeyRefreshStatus: Record<string, { tone: "success" | "error"; message: string }>;
deviceCodes: Record<string, OAuthDeviceCodeInfo>;
loginInstructions: Record<string, string>;
manualCodeConfigs: Record<string, ManualOAuthCodeInfo>;
manualCodeInputs: Record<string, string>;
setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>;
manualCodeSubmitInProgress: string | null;
loadAuthStatus: () => void | Promise<void>;
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<void>;
onReopenOnboarding?: () => void;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
authProviders: AuthProvider[];
authLoading: boolean;
authActionInProgress: string | null;
apiKeyInputs: Record<string, string>;
setApiKeyInputs: Dispatch<SetStateAction<Record<string, string>>>;
apiKeyErrors: Record<string, string>;
opencodeApiKeyRefreshStatus: Record<string, {
tone: "success" | "error";
message: string;
}>;
deviceCodes: Record<string, OAuthDeviceCodeInfo>;
loginInstructions: Record<string, string>;
manualCodeConfigs: Record<string, ManualOAuthCodeInfo>;
manualCodeInputs: Record<string, string>;
setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>;
manualCodeSubmitInProgress: string | null;
loadAuthStatus: () => void | Promise<void>;
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<void>;
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 ? (
<ClaudeCliProviderCard
compact
authenticated={claudeCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const cursorCliCard = cursorCliProvider ? (
<CursorCliProviderCard
compact
authenticated={cursorCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const llamaCppCard = llamaCppProvider ? (
<LlamaCppProviderCard
compact
authenticated={llamaCppProvider.authenticated}
onToggled={() => {
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 ? (<ClaudeCliProviderCard compact authenticated={claudeCliProvider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>) : null;
const cursorCliCard = cursorCliProvider ? (<CursorCliProviderCard compact authenticated={cursorCliProvider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>) : null;
const llamaCppCard = llamaCppProvider ? (<LlamaCppProviderCard compact authenticated={llamaCppProvider.authenticated} onToggled={() => {
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 (<>
<h4 className="settings-section-heading">{t("settings.auth.title", "Authentication")}</h4>
{authLoading ? (
<div className="settings-empty-state">{t("settings.auth.loadingStatus", "Loading authentication status…")}</div>
) : authProviders.length === 0 ? (
<div className="settings-empty-state settings-muted">
{authLoading ? (<div className="settings-empty-state">{t("settings.auth.loadingStatus", "Loading authentication status…")}</div>) : authProviders.length === 0 ? (<div className="settings-empty-state settings-muted">
{t("settings.auth.noProviders", "No providers available")}
</div>
) : (
<div className="auth-panel-body">
<PluginSlot
slotId="settings-provider-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
<PluginSlot
slotId="settings-integration-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
{!showAuthenticatedGroup && (
<div className="auth-section-hint">
</div>) : (<div className="auth-panel-body">
<PluginSlot slotId="settings-provider-card" projectId={projectId} renderPlaceholder={false} actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}/>
<PluginSlot slotId="settings-integration-card" projectId={projectId} renderPlaceholder={false} actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}/>
{!showAuthenticatedGroup && (<div className="auth-section-hint">
{t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")}
</div>
)}
{showAuthenticatedGroup && (
<div className="auth-provider-group">
</div>)}
{showAuthenticatedGroup && (<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAuthenticated", "Authenticated")}</div>
{claudeCliProvider?.authenticated && claudeCliCard}
{cursorCliProvider?.authenticated && cursorCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
{authenticatedProviders.map((provider) => (<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
<span
className="auth-provider-icon-slot"
data-testid={`auth-provider-icon-${provider.id}`}
aria-hidden="true"
>
<ProviderIcon provider={provider.id} size="md" />
<span className="auth-provider-icon-slot" data-testid={`auth-provider-icon-${provider.id}`} aria-hidden="true">
<ProviderIcon provider={provider.id} size="md"/>
</span>
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
<span data-testid={`auth-status-${provider.id}`} className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}>
{t("settings.auth.statusActive", "✓ Active")}
</span>
{provider.authenticated && provider.keyHint && (
<span className="auth-key-hint">Key: {provider.keyHint}</span>
)}
{provider.authenticated && provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
{provider.type === "api_key" ? (<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
<input type="password" className="auth-apikey-input" placeholder={t("settings.authentication.enterAPIKey", "Enter API key")} value={apiKeyInputs[provider.id] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (<button className="btn btn-sm" onClick={() => handleClearApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.auth.clearKey", "Clear")}
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
</button>) : (<button className="btn btn-primary btn-sm" onClick={() => handleSaveApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.actions.save", "Save")}
</button>
)}
</button>)}
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{authActionInProgress === provider.id && (<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>)}
{apiKeyErrors[provider.id] && (<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
</small>)}
</div>) : (<div>
{authActionInProgress === provider.id ? (<button className="btn btn-sm" disabled>
{t("settings.auth.loggingOut", "Logging out…")}
</button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
</button>) : provider.loginInProgress ? (<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
) : (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
</div>) : (<button className="btn btn-sm" onClick={() => handleLogout(provider.id)}>
{t("settings.auth.logout", "Logout")}
</button>
)}
</div>
)}
</button>)}
</div>)}
</div>
</div>
))}
</div>
)}
{showAvailableGroup && (
<div className="auth-provider-group">
</div>))}
</div>)}
{showAvailableGroup && (<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card">
{unauthenticatedProviders.map((provider) => (<div key={provider.id} className="auth-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
<span
className="auth-provider-icon-slot"
data-testid={`auth-provider-icon-${provider.id}`}
aria-hidden="true"
>
<ProviderIcon provider={provider.id} size="md" />
<span className="auth-provider-icon-slot" data-testid={`auth-provider-icon-${provider.id}`} aria-hidden="true">
<ProviderIcon provider={provider.id} size="md"/>
</span>
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
<span data-testid={`auth-status-${provider.id}`} className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}>
{t("settings.auth.statusNotConnected", "✗ Not connected")}
</span>
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
{provider.type === "api_key" ? (<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
<input type="password" className="auth-apikey-input" placeholder={t("settings.authentication.enterAPIKey", "Enter API key")} value={apiKeyInputs[provider.id] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/>
<button className="btn btn-primary btn-sm" onClick={() => handleSaveApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.actions.save", "Save")}
</button>
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{authActionInProgress === provider.id && (<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>)}
{apiKeyErrors[provider.id] && (<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
</small>)}
</div>) : (<div>
{authActionInProgress === provider.id ? (<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
</button>) : provider.loginInProgress ? (<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
</div>) : (<button className="btn btn-primary btn-sm" onClick={() => handleLogin(provider.id)}>
{t("settings.auth.login", "Login")}
</button>
)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
</button>)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button
className="btn btn-sm"
onClick={() => {
void (async () => {
<button className="btn btn-sm" onClick={() => {
void (async () => {
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast(t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
addToast(t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
}
addToast(t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), "error");
})();
}}
>
})();
}}>
{t("settings.auth.copyCode", "Copy code")}
</button>
<button
className="btn btn-sm"
onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}
>
<button className="btn btn-sm" onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
{t("settings.auth.openGitHub", "Open GitHub")}
</button>
</div>
</div>
)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<LoginInstructions
instructions={loginInstructions[provider.id]}
data-testid={`auth-login-instructions-${provider.id}`}
/>
)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<OAuthManualCodeForm
value={manualCodeInputs[provider.id] ?? ""}
onChange={(value) => 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}`}
/>
)}
</div>
)}
</div>)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<LoginInstructions instructions={loginInstructions[provider.id]} data-testid={`auth-login-instructions-${provider.id}`}/>)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<OAuthManualCodeForm value={manualCodeInputs[provider.id] ?? ""} onChange={(value) => 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}`}/>)}
</div>)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>))}
</div>)}
</div>)}
<small className="auth-hint">
{t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")}
</small>
{onReopenOnboarding && (
<div className="form-group" style={{ marginTop: "var(--space-md)" }}>
<button
type="button"
className="btn btn-sm"
onClick={onReopenOnboarding}
>
{onReopenOnboarding && (<div className="form-group" style={{ marginTop: "var(--space-md)" }}>
<button type="button" className="btn btn-sm" onClick={onReopenOnboarding}>
{t("settings.auth.reopenOnboarding", "Reopen onboarding guide")}
</button>
<small className="settings-muted">
{t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")}
</small>
</div>
)}
</div>)}
<CustomProvidersSection />
</>
);
</>);
}
export default AuthenticationSection;

View File

@@ -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}
<h4 className="settings-section-heading">Database Backups</h4>
<h4 className="settings-section-heading">{t("settings.backups.databaseBackups", "Database Backups")}</h4>
<div className="form-group">
<label htmlFor="autoBackupEnabled" className="checkbox-label">
<input
id="autoBackupEnabled"
type="checkbox"
checked={form.autoBackupEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))
}
/>
Enable automatic database backups
</label>
<small>When enabled, the database is backed up automatically on a schedule</small>
<input id="autoBackupEnabled" type="checkbox" checked={form.autoBackupEnabled || false} onChange={(e) => setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))}/>{t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups ")}</label>
<small>{t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule")}</small>
</div>
<div className="form-group">
<label htmlFor="autoBackupSchedule">Backup Schedule (Cron)</label>
<input
id="autoBackupSchedule"
type="text"
placeholder="0 2 * * *"
value={form.autoBackupSchedule || "0 2 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
<small>
Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM).
Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min)
</small>
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (
<small className="field-error">Invalid cron expression format</small>
)}
<label htmlFor="autoBackupSchedule">{t("settings.backups.backupScheduleCron", "Backup Schedule (Cron)")}</label>
<input id="autoBackupSchedule" type="text" placeholder={t("settings.backups.02", "0 2 * * *")} value={form.autoBackupSchedule || "0 2 * * *"} onChange={(e) => setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))} disabled={!form.autoBackupEnabled}/>
<small>{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) ")}</small>
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (<small className="field-error">{t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")}</small>)}
</div>
<div className="form-group">
<label htmlFor="autoBackupRetention">Retention Count</label>
<input
id="autoBackupRetention"
type="number"
min={1}
max={100}
value={form.autoBackupRetention ?? ""}
onChange={(e) => {
<label htmlFor="autoBackupRetention">{t("settings.backups.retentionCount", "Retention Count")}</label>
<input id="autoBackupRetention" type="number" min={1} max={100} value={form.autoBackupRetention ?? ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, autoBackupRetention: val === "" ? undefined : Number(val) }));
}}
disabled={!form.autoBackupEnabled}
/>
<small>Number of backup files to keep (oldest are deleted first). Range: 1-100.</small>
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (
<small className="field-error">Must be between 1 and 100</small>
)}
}} disabled={!form.autoBackupEnabled}/>
<small>{t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100.")}</small>
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (<small className="field-error">{t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")}</small>)}
</div>
<div className="form-group">
<label htmlFor="autoBackupDir">Backup Directory</label>
<input
id="autoBackupDir"
type="text"
placeholder=".fusion/backups"
value={form.autoBackupDir || ".fusion/backups"}
onChange={(e) =>
setForm((f) => ({ ...f, autoBackupDir: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
<small>Directory for backup files, relative to project root</small>
{form.autoBackupDir && form.autoBackupDir.includes("..") && (
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
)}
<label htmlFor="autoBackupDir">{t("settings.backups.backupDirectory", "Backup Directory")}</label>
<input id="autoBackupDir" type="text" placeholder={t("settings.backups.fusionBackups", ".fusion/backups")} value={form.autoBackupDir || ".fusion/backups"} onChange={(e) => setForm((f) => ({ ...f, autoBackupDir: e.target.value }))} disabled={!form.autoBackupEnabled}/>
<small>{t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to project root")}</small>
{form.autoBackupDir && form.autoBackupDir.includes("..") && (<small className="field-error">{t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")}</small>)}
</div>
<h4 className="settings-section-heading">Memory Backups</h4>
<h4 className="settings-section-heading">{t("settings.backups.memoryBackups", "Memory Backups")}</h4>
<div className="form-group">
<label htmlFor="memoryBackupEnabled" className="checkbox-label">
<input
id="memoryBackupEnabled"
type="checkbox"
checked={form.memoryBackupEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupEnabled: e.target.checked }))}
/>
Enable automatic memory backups
</label>
<small>When enabled, project and agent memory files are backed up automatically on a schedule.</small>
<input id="memoryBackupEnabled" type="checkbox" checked={form.memoryBackupEnabled || false} onChange={(e) => setForm((f) => ({ ...f, memoryBackupEnabled: e.target.checked }))}/>{t("settings.backups.enableAutomaticMemoryBackups", " Enable automatic memory backups ")}</label>
<small>{t("settings.backups.whenEnabledProjectAndAgentMemoryFilesAre", "When enabled, project and agent memory files are backed up automatically on a schedule.")}</small>
</div>
<div className="form-group">
<label htmlFor="memoryBackupSchedule">Memory Backup Schedule (Cron)</label>
<input
id="memoryBackupSchedule"
type="text"
placeholder="0 3 * * *"
value={form.memoryBackupSchedule || "0 3 * * *"}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))}
disabled={!form.memoryBackupEnabled}
/>
<small>Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).</small>
{form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && (
<small className="field-error">Invalid cron expression format</small>
)}
<label htmlFor="memoryBackupSchedule">{t("settings.backups.memoryBackupScheduleCron", "Memory Backup Schedule (Cron)")}</label>
<input id="memoryBackupSchedule" type="text" placeholder={t("settings.backups.03", "0 3 * * *")} value={form.memoryBackupSchedule || "0 3 * * *"} onChange={(e) => setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))} disabled={!form.memoryBackupEnabled}/>
<small>{t("settings.backups.cronExpressionForMemoryBackupTimingDefault0", "Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).")}</small>
{form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && (<small className="field-error">{t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")}</small>)}
</div>
<div className="form-group">
<label htmlFor="memoryBackupRetention">Memory Retention Count</label>
<input
id="memoryBackupRetention"
type="number"
min={1}
max={100}
value={form.memoryBackupRetention ?? ""}
onChange={(e) => {
<label htmlFor="memoryBackupRetention">{t("settings.backups.memoryRetentionCount", "Memory Retention Count")}</label>
<input id="memoryBackupRetention" type="number" min={1} max={100} value={form.memoryBackupRetention ?? ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, memoryBackupRetention: val === "" ? undefined : Number(val) }));
}}
disabled={!form.memoryBackupEnabled}
/>
<small>Number of memory backups to keep (oldest are deleted first). Range: 1-100.</small>
{form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && (
<small className="field-error">Must be between 1 and 100</small>
)}
}} disabled={!form.memoryBackupEnabled}/>
<small>{t("settings.backups.numberOfMemoryBackupsToKeepOldestAre", "Number of memory backups to keep (oldest are deleted first). Range: 1-100.")}</small>
{form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && (<small className="field-error">{t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")}</small>)}
</div>
<div className="form-group">
<label htmlFor="memoryBackupDir">Memory Backup Directory</label>
<input
id="memoryBackupDir"
type="text"
placeholder=".fusion/backups/memory"
value={form.memoryBackupDir || ".fusion/backups/memory"}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))}
disabled={!form.memoryBackupEnabled}
/>
<small>Directory for memory backups, relative to project root.</small>
{form.memoryBackupDir && form.memoryBackupDir.includes("..") && (
<small className="field-error">Path cannot contain parent directory traversal (..)</small>
)}
<label htmlFor="memoryBackupDir">{t("settings.backups.memoryBackupDirectory", "Memory Backup Directory")}</label>
<input id="memoryBackupDir" type="text" placeholder={t("settings.backups.fusionBackupsMemory", ".fusion/backups/memory")} value={form.memoryBackupDir || ".fusion/backups/memory"} onChange={(e) => setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))} disabled={!form.memoryBackupEnabled}/>
<small>{t("settings.backups.directoryForMemoryBackupsRelativeToProjectRoot", "Directory for memory backups, relative to project root.")}</small>
{form.memoryBackupDir && form.memoryBackupDir.includes("..") && (<small className="field-error">{t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")}</small>)}
</div>
<div className="form-group">
<label htmlFor="memoryBackupScope">Memory Backup Scope</label>
<select
id="memoryBackupScope"
value={form.memoryBackupScope || "all"}
onChange={(e) => setForm((f) => ({ ...f, memoryBackupScope: e.target.value as "project" | "agents" | "all" }))}
disabled={!form.memoryBackupEnabled}
>
<option value="all">All (project + agents)</option>
<option value="project">Project only (.fusion/memory)</option>
<option value="agents">Agents only (.fusion/agent-memory)</option>
<label htmlFor="memoryBackupScope">{t("settings.backups.memoryBackupScope", "Memory Backup Scope")}</label>
<select id="memoryBackupScope" value={form.memoryBackupScope || "all"} onChange={(e) => setForm((f) => ({ ...f, memoryBackupScope: e.target.value as "project" | "agents" | "all" }))} disabled={!form.memoryBackupEnabled}>
<option value="all">{t("settings.backups.allProjectAgents", "All (project + agents)")}</option>
<option value="project">{t("settings.backups.projectOnlyFusionMemory", "Project only (.fusion/memory)")}</option>
<option value="agents">{t("settings.backups.agentsOnlyFusionAgentMemory", "Agents only (.fusion/agent-memory)")}</option>
</select>
</div>
{backupLoading ? (
<div className="settings-empty-state">Loading backup info…</div>
) : backupInfo ? (
<div className="form-group">
<label>Current Backups</label>
{backupLoading ? (<div className="settings-empty-state">{t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")}</div>) : backupInfo ? (<div className="form-group">
<label>{t("settings.backups.currentBackups", "Current Backups")}</label>
<div className="backup-stats">
<div className="backup-stat">
<span className="backup-stat-value">{backupInfo.count}</span>
<span className="backup-stat-label">backups</span>
<span className="backup-stat-label">{t("settings.backups.backups", "backups")}</span>
</div>
<div className="backup-stat">
<span className="backup-stat-value">
{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`}
</span>
<span className="backup-stat-label">total size</span>
<span className="backup-stat-label">{t("settings.backups.totalSize", "total size")}</span>
</div>
</div>
{backupInfo.backups.length > 0 && (
<details className="backup-list">
<summary>View {backupInfo.backups.length} backup(s)</summary>
{backupInfo.backups.length > 0 && (<details className="backup-list">
<summary>{t("settings.backups.view", "View ")}{backupInfo.backups.length}{t("settings.backups.backupS", " backup(s)")}</summary>
<ul>
{backupInfo.backups.slice(0, 10).map((backup) => (
<li key={backup.filename}>
{backupInfo.backups.slice(0, 10).map((backup) => (<li key={backup.filename}>
<code>{backup.filename}</code>
<span className="backup-size">
{backup.size > 1024 * 1024
? `${(backup.size / (1024 * 1024)).toFixed(1)} MB`
: `${(backup.size / 1024).toFixed(1)} KB`}
</span>
</li>
))}
{backupInfo.backups.length > 10 && (
<li><em>...and {backupInfo.backups.length - 10} more</em></li>
)}
</li>))}
{backupInfo.backups.length > 10 && (<li><em>{t("settings.backups.and", "...and ")}{backupInfo.backups.length - 10}{t("settings.backups.more", " more")}</em></li>)}
</ul>
</details>
)}
</div>
) : null}
</details>)}
</div>) : null}
<div className="form-group">
<button
type="button"
className="btn btn-sm"
onClick={onBackupNow}
disabled={backupLoading}
>
<button type="button" className="btn btn-sm" onClick={onBackupNow} disabled={backupLoading}>
{backupLoading ? t("settings.backups.creating", "Creating…") : t("settings.backups.backupNow", "Backup Now")}
</button>
</div>
</>
);
</>);
}
export default BackupsSection;

View File

@@ -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}
<h4 className="settings-section-heading">Commands</h4>
<h4 className="settings-section-heading">{t("settings.commands.commands", "Commands")}</h4>
<div className="form-group">
<label htmlFor="testCommand">Test Command</label>
<input
id="testCommand"
type="text"
placeholder="e.g. pnpm test"
value={form.testCommand || ""}
onChange={(e) =>
setForm((f) => ({ ...f, testCommand: e.target.value || undefined }))
}
/>
<small>Command used to run tests — injected into generated task specs</small>
<label htmlFor="testCommand">{t("settings.commands.testCommand", "Test Command")}</label>
<input id="testCommand" type="text" placeholder={t("settings.commands.eGPnpmTest", "e.g. pnpm test")} value={form.testCommand || ""} onChange={(e) => setForm((f) => ({ ...f, testCommand: e.target.value || undefined }))}/>
<small>{t("settings.commands.commandUsedToRunTestsInjectedIntoGenerated", "Command used to run tests \u2014 injected into generated task specs")}</small>
</div>
<div className="form-group">
<label htmlFor="buildCommand">Build Command</label>
<input
id="buildCommand"
type="text"
placeholder="e.g. pnpm build"
value={form.buildCommand || ""}
onChange={(e) =>
setForm((f) => ({ ...f, buildCommand: e.target.value || undefined }))
}
/>
<small>Command used to build the project — injected into generated task specs</small>
<label htmlFor="buildCommand">{t("settings.commands.buildCommand", "Build Command")}</label>
<input id="buildCommand" type="text" placeholder={t("settings.commands.eGPnpmBuild", "e.g. pnpm build")} value={form.buildCommand || ""} onChange={(e) => setForm((f) => ({ ...f, buildCommand: e.target.value || undefined }))}/>
<small>{t("settings.commands.commandUsedToBuildTheProjectInjectedInto", "Command used to build the project \u2014 injected into generated task specs")}</small>
</div>
</>
);
</>);
}
export default CommandsSection;

View File

@@ -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<string, string>;
/** Map of legacy alias key -> canonical key. */
legacyAliases: Record<string, string>;
/** Canonicalize a possibly-legacy feature key. */
getCanonicalKey: (key: string) => string;
/** Whether a feature is enabled, honoring legacy aliases. */
isFeatureEnabled: (features: Record<string, boolean>, key: string) => boolean;
scopeBanner: ReactNode;
/** Display labels for well-known features (always rendered). */
knownFeatures: Record<string, string>;
/** Map of legacy alias key -> canonical key. */
legacyAliases: Record<string, string>;
/** Canonicalize a possibly-legacy feature key. */
getCanonicalKey: (key: string) => string;
/** Whether a feature is enabled, honoring legacy aliases. */
isFeatureEnabled: (features: Record<string, boolean>, 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}
<h4 className="settings-section-heading">Experimental Features</h4>
<h4 className="settings-section-heading">{t("settings.experimental.experimentalFeatures", "Experimental Features")}</h4>
<div className="form-group">
<small>
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.
</small>
<small>{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. ")}</small>
</div>
<div className="form-group">
<label>Feature Flags</label>
<label>{t("settings.experimental.featureFlags", "Feature Flags")}</label>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{featureFlags.map(([key, enabled]) => (
<label key={key} htmlFor={`experimental-${key}`} className="checkbox-label">
<input
id={`experimental-${key}`}
type="checkbox"
checked={enabled}
onChange={(e) => {
setForm((f) => {
{featureFlags.map(([key, enabled]) => (<label key={key} htmlFor={`experimental-${key}`} className="checkbox-label">
<input id={`experimental-${key}`} type="checkbox" checked={enabled} onChange={(e) => {
setForm((f) => {
const nextExperimentalFeatures = {
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
};
for (const [legacyKey, canonicalKey] of Object.entries(legacyAliases)) {
if (canonicalKey === key) {
delete nextExperimentalFeatures[legacyKey];
}
if (canonicalKey === key) {
delete nextExperimentalFeatures[legacyKey];
}
}
return {
...f,
experimentalFeatures: nextExperimentalFeatures,
...f,
experimentalFeatures: nextExperimentalFeatures,
};
});
}}
/>
});
}}/>
<span>{knownFeatures[key] ?? key}</span>
</label>
))}
</label>))}
</div>
</div>
</>
);
</>);
}
export default ExperimentalSection;

View File

@@ -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<WorkflowDefinition[]>([]);
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<WorkflowDefinition[]>([]);
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}
<h4 className="settings-section-heading">General</h4>
<h4 className="settings-section-heading">{t("settings.general.general", "General")}</h4>
<div className="form-group">
<label htmlFor="taskPrefix">Task Prefix</label>
<input
id="taskPrefix"
type="text"
placeholder="FN"
value={form.taskPrefix || ""}
onChange={(e) => {
<label htmlFor="taskPrefix">{t("settings.general.taskPrefix", "Task Prefix")}</label>
<input id="taskPrefix" type="text" placeholder={t("settings.general.fN", "FN")} value={form.taskPrefix || ""} onChange={(e) => {
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 && <small className="field-error">{prefixError}</small>}
{!prefixError && <small>Prefix for new task IDs (e.g. KB, PROJ)</small>}
{!prefixError && <small>{t("settings.general.prefixForNewTaskIDsEGKB", "Prefix for new task IDs (e.g. KB, PROJ)")}</small>}
</div>
<div className="form-group">
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast} />
<small>New tasks inherit this custom workflow's steps (overridable per task)</small>
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast}/>
<small>{t("settings.general.newTasksInheritThisCustomWorkflowsStepsOverridable", "New tasks inherit this custom workflow's steps (overridable per task)")}</small>
</div>
{builtinWorkflows.length > 0 && (
<div className="form-group">
<label>Built-in workflows</label>
{builtinWorkflows.length > 0 && (<div className="form-group">
<label>{t("settings.general.builtInWorkflows", "Built-in workflows")}</label>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{builtinWorkflows.map((workflow) => (
<label key={workflow.id} htmlFor={`builtin-workflow-${workflow.id}`} className="checkbox-label">
<input
id={`builtin-workflow-${workflow.id}`}
type="checkbox"
checked={enabledBuiltinWorkflowIds.has(workflow.id)}
onChange={(e) => setBuiltinWorkflowEnabled(workflow.id, e.target.checked)}
/>
{builtinWorkflows.map((workflow) => (<label key={workflow.id} htmlFor={`builtin-workflow-${workflow.id}`} className="checkbox-label">
<input id={`builtin-workflow-${workflow.id}`} type="checkbox" checked={enabledBuiltinWorkflowIds.has(workflow.id)} onChange={(e) => setBuiltinWorkflowEnabled(workflow.id, e.target.checked)}/>
<span>{workflow.name}</span>
</label>
))}
</label>))}
</div>
<small>Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve.</small>
</div>
)}
<small>{t("settings.general.disabledBuiltInWorkflowsAreHiddenFromWorkflow", "Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve.")}</small>
</div>)}
<div className="form-group">
<label htmlFor="ephemeralAgentsEnabled" className="checkbox-label">
<input
id="ephemeralAgentsEnabled"
type="checkbox"
checked={form.ephemeralAgentsEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))
}
/>
Use ephemeral task-worker agents
</label>
<small>
When enabled (default), Fusion spawns short-lived <code>executor-FN-XXXX</code> 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.
</small>
<input id="ephemeralAgentsEnabled" type="checkbox" checked={form.ephemeralAgentsEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))}/>{t("settings.general.useEphemeralTaskWorkerAgents", " Use ephemeral task-worker agents ")}</label>
<small>{t("settings.general.whenEnabledDefaultFusionSpawnsShortLived", " When enabled (default), Fusion spawns short-lived ")}<code>executor-FN-XXXX</code>{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. ")}</small>
</div>
<div className="form-group">
<label htmlFor="completionDocumentationMode">Completion Documentation Automation</label>
<select
id="completionDocumentationMode"
value={form.completionDocumentationMode || "off"}
onChange={(e) =>
setForm((f) => ({
...f,
completionDocumentationMode: e.target.value as "off" | "changeset" | "changelog",
}))
}
>
<option value="off">Off</option>
<option value="changeset">Require changeset (.changeset/*.md)</option>
<option value="changelog">Require changelog update (existing changelog)</option>
<label htmlFor="completionDocumentationMode">{t("settings.general.completionDocumentationAutomation", "Completion Documentation Automation")}</label>
<select id="completionDocumentationMode" value={form.completionDocumentationMode || "off"} onChange={(e) => setForm((f) => ({
...f,
completionDocumentationMode: e.target.value as "off" | "changeset" | "changelog",
}))}>
<option value="off">{t("settings.general.off", "Off")}</option>
<option value="changeset">{t("settings.general.requireChangesetChangesetMd", "Require changeset (.changeset/*.md)")}</option>
<option value="changelog">{t("settings.general.requireChangelogUpdateExistingChangelog", "Require changelog update (existing changelog)")}</option>
</select>
<small>
Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow
<code>.changeset</code> workflows, or changelog mode when contributors should update an existing changelog file.
</small>
<small>{t("settings.general.controlsHowFutureTaskSpecsHandleReleaseNote", " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ")}<code>.changeset</code>{t("settings.general.workflowsOrChangelogModeWhenContributorsShouldUpdate", " workflows, or changelog mode when contributors should update an existing changelog file. ")}</small>
</div>
<div className="form-group">
<label htmlFor="showQuickChatFAB" className="checkbox-label">
<input
id="showQuickChatFAB"
type="checkbox"
checked={form.showQuickChatFAB === true}
onChange={(e) =>
setForm((f) => ({ ...f, showQuickChatFAB: e.target.checked }))
}
/>
Show quick chat button
</label>
<small>Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.</small>
<input id="showQuickChatFAB" type="checkbox" checked={form.showQuickChatFAB === true} onChange={(e) => setForm((f) => ({ ...f, showQuickChatFAB: e.target.checked }))}/>{t("settings.general.showQuickChatButton", " Show quick chat button ")}</label>
<small>{t("settings.general.showTheFloatingChatButtonInTheDashboard", "Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.")}</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Chat history</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.chatHistory", "Chat history")}</h4>
<div className="form-group">
<label htmlFor="chatAutoCleanupDays">Auto-cleanup old chats</label>
<select
id="chatAutoCleanupDays"
className="select"
value={form.chatAutoCleanupDays ?? 0}
onChange={(e) =>
setForm((f) => ({ ...f, chatAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<label htmlFor="chatAutoCleanupDays">{t("settings.general.autoCleanupOldChats", "Auto-cleanup old chats")}</label>
<select id="chatAutoCleanupDays" className="select" value={form.chatAutoCleanupDays ?? 0} onChange={(e) => setForm((f) => ({ ...f, chatAutoCleanupDays: Number(e.target.value) || 0 }))}>
<option value={0}>{t("settings.general.off", "Off")}</option>
<option value={7}>{t("settings.general.7Days", "7 days")}</option>
<option value={14}>{t("settings.general.14Days", "14 days")}</option>
<option value={30}>{t("settings.general.30Days", "30 days")}</option>
<option value={60}>{t("settings.general.60Days", "60 days")}</option>
<option value={90}>{t("settings.general.90Days", "90 days")}</option>
</select>
<small>Delete chat sessions and rooms that have been idle for this many days. Default: Off.</small>
<small>{t("settings.general.deleteChatSessionsAndRoomsThatHaveBeen", "Delete chat sessions and rooms that have been idle for this many days. Default: Off.")}</small>
</div>
<div className="form-group">
<label htmlFor="mailAutoCleanupDays">Auto-prune old mail</label>
<select
id="mailAutoCleanupDays"
className="select"
value={form.mailAutoCleanupDays ?? 0}
onChange={(e) =>
setForm((f) => ({ ...f, mailAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<label htmlFor="mailAutoCleanupDays">{t("settings.general.autoPruneOldMail", "Auto-prune old mail")}</label>
<select id="mailAutoCleanupDays" className="select" value={form.mailAutoCleanupDays ?? 0} onChange={(e) => setForm((f) => ({ ...f, mailAutoCleanupDays: Number(e.target.value) || 0 }))}>
<option value={0}>{t("settings.general.off", "Off")}</option>
<option value={7}>{t("settings.general.7Days", "7 days")}</option>
<option value={14}>{t("settings.general.14Days", "14 days")}</option>
<option value={30}>{t("settings.general.30Days", "30 days")}</option>
<option value={60}>{t("settings.general.60Days", "60 days")}</option>
<option value={90}>{t("settings.general.90Days", "90 days")}</option>
</select>
<small>Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.</small>
<small>{t("settings.general.deleteInboxOutboxMessagesOlderThanThisMany", "Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.")}</small>
</div>
<div className="form-group">
<label htmlFor="operationalLogRetentionDays">Operational log retention</label>
<select
id="operationalLogRetentionDays"
className="select"
value={form.operationalLogRetentionDays ?? 30}
onChange={(e) =>
setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<label htmlFor="operationalLogRetentionDays">{t("settings.general.operationalLogRetention", "Operational log retention")}</label>
<select id="operationalLogRetentionDays" className="select" value={form.operationalLogRetentionDays ?? 30} onChange={(e) => setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))}>
<option value={0}>{t("settings.general.off", "Off")}</option>
<option value={7}>{t("settings.general.7Days", "7 days")}</option>
<option value={14}>{t("settings.general.14Days", "14 days")}</option>
<option value={30}>{t("settings.general.30Days", "30 days")}</option>
<option value={60}>{t("settings.general.60Days", "60 days")}</option>
<option value={90}>{t("settings.general.90Days", "90 days")}</option>
</select>
<small>
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.
</small>
<small>{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. ")}</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Chat Rooms</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.chatRooms", "Chat Rooms")}</h4>
<div className="form-group">
<label htmlFor="chatRoomRecentVerbatimMessages">Recent verbatim room messages</label>
<input
id="chatRoomRecentVerbatimMessages"
type="number"
min="1"
className="input"
placeholder="25"
value={form.chatRoomRecentVerbatimMessages ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined }))
}
/>
<small>Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.</small>
<label htmlFor="chatRoomRecentVerbatimMessages">{t("settings.general.recentVerbatimRoomMessages", "Recent verbatim room messages")}</label>
<input id="chatRoomRecentVerbatimMessages" type="number" min="1" className="input" placeholder={t("settings.general.25", "25")} value={form.chatRoomRecentVerbatimMessages ?? ""} onChange={(e) => setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined }))}/>
<small>{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.")}</small>
</div>
<div className="form-group">
<label htmlFor="chatRoomCompactionFetchLimit">Room compaction fetch limit</label>
<input
id="chatRoomCompactionFetchLimit"
type="number"
min="1"
className="input"
placeholder="200"
value={form.chatRoomCompactionFetchLimit ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined }))
}
/>
<small>Upper bound on messages fetched from the room store for compaction consideration. Default: 200.</small>
<label htmlFor="chatRoomCompactionFetchLimit">{t("settings.general.roomCompactionFetchLimit", "Room compaction fetch limit")}</label>
<input id="chatRoomCompactionFetchLimit" type="number" min="1" className="input" placeholder={t("settings.general.200", "200")} value={form.chatRoomCompactionFetchLimit ?? ""} onChange={(e) => setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined }))}/>
<small>{t("settings.general.upperBoundOnMessagesFetchedFromTheRoom", "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.")}</small>
</div>
<div className="form-group">
<label htmlFor="chatRoomSummaryMaxChars">Room summary max characters</label>
<input
id="chatRoomSummaryMaxChars"
type="number"
min="200"
className="input"
placeholder="3000"
value={form.chatRoomSummaryMaxChars ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined }))
}
/>
<small>Hard cap on the synthesized "Earlier room context" summary block. Default: 3000.</small>
<label htmlFor="chatRoomSummaryMaxChars">{t("settings.general.roomSummaryMaxCharacters", "Room summary max characters")}</label>
<input id="chatRoomSummaryMaxChars" type="number" min="200" className="input" placeholder={t("settings.general.3000", "3000")} value={form.chatRoomSummaryMaxChars ?? ""} onChange={(e) => setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined }))}/>
<small>{t("settings.general.hardCapOnTheSynthesizedEarlierRoomContext", "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000.")}</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Capacity Risk Banner</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.capacityRiskBanner", "Capacity Risk Banner")}</h4>
<div className="form-group">
<label htmlFor="capacityRiskBannerEnabled" className="checkbox-label">
<input
id="capacityRiskBannerEnabled"
type="checkbox"
checked={form.capacityRiskBannerEnabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, capacityRiskBannerEnabled: e.target.checked }))
}
/>
Show capacity risk banner
</label>
<small>Warn on the board when todo work exceeds the threshold and no idle agents are available.</small>
<input id="capacityRiskBannerEnabled" type="checkbox" checked={form.capacityRiskBannerEnabled === true} onChange={(e) => setForm((f) => ({ ...f, capacityRiskBannerEnabled: e.target.checked }))}/>{t("settings.general.showCapacityRiskBanner", " Show capacity risk banner ")}</label>
<small>{t("settings.general.warnOnTheBoardWhenTodoWorkExceeds", "Warn on the board when todo work exceeds the threshold and no idle agents are available.")}</small>
</div>
<div className="form-group">
<label htmlFor="capacityRiskTodoThresholdGeneral">Todo threshold</label>
<input
id="capacityRiskTodoThresholdGeneral"
type="number"
min={0}
className="input"
value={form.capacityRiskTodoThreshold ?? 20}
onChange={(e) =>
setForm((f) => ({
...f,
capacityRiskTodoThreshold:
e.target.value === ""
? 0
: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
}))
}
/>
<small>Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.</small>
<label htmlFor="capacityRiskTodoThresholdGeneral">{t("settings.general.todoThreshold", "Todo threshold")}</label>
<input id="capacityRiskTodoThresholdGeneral" type="number" min={0} className="input" value={form.capacityRiskTodoThreshold ?? 20} onChange={(e) => setForm((f) => ({
...f,
capacityRiskTodoThreshold: e.target.value === ""
? 0
: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
}))}/>
<small>{t("settings.general.bannerFiresWhenTodoCountIsStrictlyGreater", "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.")}</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Tracking</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.gitHubTracking", "GitHub Tracking")}</h4>
<div className="form-group">
<label htmlFor="githubTrackingMode">Default tracking mode for new tasks</label>
<select
id="githubTrackingMode"
className="select"
value={form.githubTrackingEnabledByDefault ? "new-tasks" : "off"}
onChange={(e) =>
setForm((f) => ({
...f,
githubTrackingEnabledByDefault: e.target.value === "new-tasks",
}))
}
>
<option value="off">Off (default)</option>
<option value="new-tasks">On for new tasks</option>
<label htmlFor="githubTrackingMode">{t("settings.general.defaultTrackingModeForNewTasks", "Default tracking mode for new tasks")}</label>
<select id="githubTrackingMode" className="select" value={form.githubTrackingEnabledByDefault ? "new-tasks" : "off"} onChange={(e) => setForm((f) => ({
...f,
githubTrackingEnabledByDefault: e.target.value === "new-tasks",
}))}>
<option value="off">{t("settings.general.offDefault", "Off (default)")}</option>
<option value="new-tasks">{t("settings.general.onForNewTasks", "On for new tasks")}</option>
</select>
<small>
Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal.
</small>
<small>
Tracking issues use this task&apos;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."
<small>{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. ")}</small>
<small>{t("settings.general.trackingIssuesUseThisTaskAposSTitle", " Tracking issues use this task&apos;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.")
: ""}
</small>
</div>
<div className="form-group">
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">Project default tracking repo</label>
<TrackingRepoSelect
id="projectGithubTrackingDefaultRepoGeneral"
ariaLabel="Project default tracking repo"
value={form.githubTrackingDefaultRepo ?? ""}
options={projectTrackingRepoOptions}
loading={projectTrackingRepoLoading}
error={projectTrackingRepoError ?? undefined}
placeholder="owner/repo"
onChange={(nextValue) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
<small>Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.</small>
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">{t("settings.general.projectDefaultTrackingRepo", "Project default tracking repo")}</label>
<TrackingRepoSelect id="projectGithubTrackingDefaultRepoGeneral" ariaLabel="Project default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={projectTrackingRepoOptions} loading={projectTrackingRepoLoading} error={projectTrackingRepoError ?? undefined} placeholder={t("settings.general.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>
<small>{t("settings.general.defaultRepoUsedWhenCreatingGitHubIssuesFor", "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.")}</small>
</div>
<div className="form-group">
<label htmlFor="githubTrackingDedupEnabled" className="checkbox-label">
<input
id="githubTrackingDedupEnabled"
type="checkbox"
checked={form.githubTrackingDedupEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))
}
/>
Search the tracking repo for likely duplicates before opening a new issue
</label>
<small>
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.
</small>
<input id="githubTrackingDedupEnabled" type="checkbox" checked={form.githubTrackingDedupEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))}/>{t("settings.general.searchTheTrackingRepoForLikelyDuplicatesBefore", " Search the tracking repo for likely duplicates before opening a new issue ")}</label>
<small>{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. ")}</small>
</div>
</>
);
</>);
}
export default GeneralSection;

View File

@@ -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}
<h4 className="settings-section-heading">General</h4>
<h4 className="settings-section-heading">{t("settings.globalGeneral.general", "General")}</h4>
<div className="form-group">
<label htmlFor="globalGithubTrackingDefaultRepo">Global default tracking repo</label>
<TrackingRepoSelect
id="globalGithubTrackingDefaultRepo"
ariaLabel="Global default tracking repo"
value={form.githubTrackingDefaultRepo ?? ""}
options={globalTrackingRepoOptions}
loading={globalTrackingRepoLoading}
error={globalTrackingRepoError ?? undefined}
placeholder="owner/repo"
onChange={(nextValue) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
<small>Projects inherit this value when they do not set a project default tracking repo.</small>
<label htmlFor="globalGithubTrackingDefaultRepo">{t("settings.globalGeneral.globalDefaultTrackingRepo", "Global default tracking repo")}</label>
<TrackingRepoSelect id="globalGithubTrackingDefaultRepo" ariaLabel="Global default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={globalTrackingRepoOptions} loading={globalTrackingRepoLoading} error={globalTrackingRepoError ?? undefined} placeholder={t("settings.globalGeneral.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>
<small>{t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo.")}</small>
</div>
<CliBinaryPanel />
<div className="form-group">
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
<input
id="persistAgentToolOutput"
type="checkbox"
checked={form.persistAgentToolOutput !== false}
onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}
/>
Save tool output in agent logs
</label>
<small>
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.
</small>
<input id="persistAgentToolOutput" type="checkbox" checked={form.persistAgentToolOutput !== false} onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}/>{t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs ")}</label>
<small>{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. ")}</small>
</div>
<div className="form-group">
<h5 className="settings-section-heading">Save AI thinking logs</h5>
<h5 className="settings-section-heading">{t("settings.globalGeneral.saveAIThinkingLogs", "Save AI thinking logs")}</h5>
<label htmlFor="persistAgentThinkingLogPermanent" className="checkbox-label">
<input
id="persistAgentThinkingLogPermanent"
type="checkbox"
checked={resolvePersistAgentThinkingLog(form, { ephemeral: false })}
onChange={(e) =>
setForm((f) => ({ ...f, persistAgentThinkingLogPermanent: e.target.checked }))
}
/>
Save AI thinking for permanent agents
</label>
<input id="persistAgentThinkingLogPermanent" type="checkbox" checked={resolvePersistAgentThinkingLog(form, { ephemeral: false })} onChange={(e) => setForm((f) => ({ ...f, persistAgentThinkingLogPermanent: e.target.checked }))}/>{t("settings.globalGeneral.saveAIThinkingForPermanentAgents", " Save AI thinking for permanent agents ")}</label>
<label htmlFor="persistAgentThinkingLogEphemeral" className="checkbox-label">
<input
id="persistAgentThinkingLogEphemeral"
type="checkbox"
checked={resolvePersistAgentThinkingLog(form, { ephemeral: true })}
onChange={(e) =>
setForm((f) => ({ ...f, persistAgentThinkingLogEphemeral: e.target.checked }))
}
/>
Save AI thinking for ephemeral / task-worker agents
</label>
<small>
Leave both thinking toggles off to keep the original default behavior.
This only controls persisted <code>thinking</code> rows and does not affect assistant text or tool rows.
</small>
<input id="persistAgentThinkingLogEphemeral" type="checkbox" checked={resolvePersistAgentThinkingLog(form, { ephemeral: true })} onChange={(e) => setForm((f) => ({ ...f, persistAgentThinkingLogEphemeral: e.target.checked }))}/>{t("settings.globalGeneral.saveAIThinkingForEphemeralTaskWorkerAgents", " Save AI thinking for ephemeral / task-worker agents ")}</label>
<small>{t("settings.globalGeneral.leaveBothThinkingTogglesOffToKeepThe", " Leave both thinking toggles off to keep the original default behavior. This only controls persisted ")}<code>thinking</code>{t("settings.globalGeneral.rowsAndDoesNotAffectAssistantTextOr", " rows and does not affect assistant text or tool rows. ")}</small>
</div>
<div className="form-group">
<label htmlFor="fnBinaryCheckEnabled" className="checkbox-label">
<input
id="fnBinaryCheckEnabled"
type="checkbox"
checked={form.fnBinaryCheckEnabled !== false}
onChange={(e) => setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}
/>
Check for the <code>fn</code> CLI binary on PATH
</label>
<small>
When enabled, the dashboard probes for a globally-installed{" "}
<code>fn</code> / <code>fusion</code> CLI by spawning{" "}
<code>&lt;bin&gt; --version</code>. Disable this if your local
dev process is the source of truth and you don&apos;t want any
outdated globally-installed binary executed during the probe.
</small>
<input id="fnBinaryCheckEnabled" type="checkbox" checked={form.fnBinaryCheckEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}/>{t("settings.globalGeneral.checkForThe", " Check for the ")}<code>fn</code>{t("settings.globalGeneral.cLIBinaryOnPATH", " CLI binary on PATH ")}</label>
<small>{t("settings.globalGeneral.whenEnabledTheDashboardProbesForAGlobally", " When enabled, the dashboard probes for a globally-installed")}{" "}
<code>fn</code> / <code>fusion</code>{t("settings.globalGeneral.cLIBySpawning", " CLI by spawning")}{" "}
<code>&lt;bin&gt; --version</code>{t("settings.globalGeneral.disableThisIfYourLocalDevProcessIs", ". Disable this if your local dev process is the source of truth and you don&apos;t want any outdated globally-installed binary executed during the probe. ")}</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Updates</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalGeneral.updates", "Updates")}</h4>
<div className="form-group">
<label htmlFor="updateCheckEnabled" className="checkbox-label">
<input
id="updateCheckEnabled"
type="checkbox"
checked={form.updateCheckEnabled !== false}
onChange={(e) => setForm((f) => ({ ...f, updateCheckEnabled: e.target.checked }))}
/>
Check for updates automatically
</label>
<small>
When enabled, Fusion checks npm for new versions of{" "}
<code>@runfusion/fusion</code> and shows update notices in the CLI and dashboard.
Cadence is governed by the frequency below.
</small>
<input id="updateCheckEnabled" type="checkbox" checked={form.updateCheckEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, updateCheckEnabled: e.target.checked }))}/>{t("settings.globalGeneral.checkForUpdatesAutomatically", " Check for updates automatically ")}</label>
<small>{t("settings.globalGeneral.whenEnabledFusionChecksNpmForNewVersions", " When enabled, Fusion checks npm for new versions of")}{" "}
<code>@runfusion/fusion</code>{t("settings.globalGeneral.andShowsUpdateNoticesInTheCLIAnd", " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. ")}</small>
</div>
<div className="form-group">
<label htmlFor="updateCheckFrequency">Frequency</label>
<select
id="updateCheckFrequency"
value={form.updateCheckFrequency ?? "daily"}
onChange={(e) =>
setForm((f) => ({
...f,
updateCheckFrequency: e.target.value as "manual" | "on-startup" | "daily" | "weekly",
}))
}
disabled={form.updateCheckEnabled === false}
>
<option value="manual">Manual only — never auto-check</option>
<option value="on-startup">On startup — once per server launch</option>
<option value="daily">Daily (recommended)</option>
<option value="weekly">Weekly</option>
<label htmlFor="updateCheckFrequency">{t("settings.globalGeneral.frequency", "Frequency")}</label>
<select id="updateCheckFrequency" value={form.updateCheckFrequency ?? "daily"} onChange={(e) => setForm((f) => ({
...f,
updateCheckFrequency: e.target.value as "manual" | "on-startup" | "daily" | "weekly",
}))} disabled={form.updateCheckEnabled === false}>
<option value="manual">{t("settings.globalGeneral.manualOnlyNeverAutoCheck", "Manual only \u2014 never auto-check")}</option>
<option value="on-startup">{t("settings.globalGeneral.onStartupOncePerServerLaunch", "On startup \u2014 once per server launch")}</option>
<option value="daily">{t("settings.globalGeneral.dailyRecommended", "Daily (recommended)")}</option>
<option value="weekly">{t("settings.globalGeneral.weekly", "Weekly")}</option>
</select>
<small>
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.
</small>
<small>{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. ")}</small>
</div>
<div className="form-group">
<label htmlFor="autoReloadOnVersionChange" className="checkbox-label">
<input
id="autoReloadOnVersionChange"
type="checkbox"
checked={form.autoReloadOnVersionChange !== false}
onChange={(e) => setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))}
/>
Auto-reload dashboard on version change
</label>
<small>
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.
</small>
<input id="autoReloadOnVersionChange" type="checkbox" checked={form.autoReloadOnVersionChange !== false} onChange={(e) => setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))}/>{t("settings.globalGeneral.autoReloadDashboardOnVersionChange", " Auto-reload dashboard on version change ")}</label>
<small>{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. ")}</small>
</div>
</>
);
</>);
}
export default GlobalGeneralSection;

View File

@@ -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 --- */}
<h4 className="settings-section-heading">Default Model</h4>
{modelsLoading ? (
<div className="settings-empty-state">{t("settings.models.loadingModels", "Loading available models…")}</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
<h4 className="settings-section-heading">{t("settings.globalModels.defaultModel", "Default Model")}</h4>
{modelsLoading ? (<div className="settings-empty-state">{t("settings.models.loadingModels", "Loading available models…")}</div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">
{t("settings.models.noModels", "No models available. Configure authentication first.")}
</div>
) : (
<>
</div>) : (<>
<div className="form-group">
<label htmlFor="defaultModel">Default Model</label>
<CustomModelDropdown
id="defaultModel"
label="Default Model"
models={availableModels}
value={selectedValue}
onChange={(val) => {
<label htmlFor="defaultModel">{t("settings.globalModels.defaultModel", "Default Model")}</label>
<CustomModelDropdown id="defaultModel" label="Default Model" models={availableModels} value={selectedValue} onChange={(val) => {
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}
/>
<small>Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.</small>
</div>
<div className="form-group">
<label htmlFor="fallbackModel">Fallback Model</label>
<CustomModelDropdown
id="fallbackModel"
label="Fallback Model"
models={availableModels}
value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""}
onChange={(val) => {
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}
/>
<small>Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.</small>
</div>
</>
)}
{(() => {
const selectedModel = availableModels.find(
(m) => m.provider === form.defaultProvider && m.id === form.defaultModelId,
);
if (selectedModel && !selectedModel.reasoning) return null;
return (
<div className="form-group">
{/* 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. */}
<label htmlFor="defaultThinkingLevel">Thinking Effort</label>
<select
id="defaultThinkingLevel"
value={form.defaultThinkingLevel || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultThinkingLevel: (val as ThinkingLevel) || undefined }));
}}
>
<option value="">Default</option>
{THINKING_LEVELS.map((level) => (
<option key={level} value={level}>
{level.charAt(0).toUpperCase() + level.slice(1)}
</option>
))}
</select>
<small>Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.</small>
</div>
);
})()}
{availableModels.length > 0 && (
<>
<h4 className="settings-section-heading settings-section-heading--spaced">Model Lanes</h4>
<p className="settings-description">
Global baseline models for each AI role. Project settings can override these per-project.
</p>
{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 (
<div className="form-group" key={`global-${lane.laneId}`}>
<label htmlFor={`global-${lane.laneId}-model`}>{lane.label}</label>
<CustomModelDropdown
id={`global-${lane.laneId}-model`}
label={lane.label}
models={availableModels}
value={value}
onChange={(selected) => {
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}/>
<small>{t("settings.globalModels.defaultAIModelUsedForTaskExecutionWhen", "Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.")}</small>
</div>
<div className="form-group">
<label htmlFor="fallbackModel">{t("settings.globalModels.fallbackModel", "Fallback Model")}</label>
<CustomModelDropdown id="fallbackModel" label="Fallback Model" models={availableModels} value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""} onChange={(val) => {
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}/>
<small>{t("settings.globalModels.usedAutomaticallyIfThePrimaryDefaultModelHits", "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.")}</small>
</div>
</>)}
{(() => {
const selectedModel = availableModels.find((m) => m.provider === form.defaultProvider && m.id === form.defaultModelId);
if (selectedModel && !selectedModel.reasoning)
return null;
return (<div className="form-group">
{/* 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. */}
<label htmlFor="defaultThinkingLevel">{t("settings.globalModels.thinkingEffort", "Thinking Effort")}</label>
<select id="defaultThinkingLevel" value={form.defaultThinkingLevel || ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultThinkingLevel: (val as ThinkingLevel) || undefined }));
}}>
<option value="">{t("settings.globalModels.default", "Default")}</option>
{THINKING_LEVELS.map((level) => (<option key={level} value={level}>
{level.charAt(0).toUpperCase() + level.slice(1)}
</option>))}
</select>
<small>{t("settings.globalModels.controlsHowMuchReasoningEffortTheAIModel", "Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.")}</small>
</div>);
})()}
{availableModels.length > 0 && (<>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalModels.modelLanes", "Model Lanes")}</h4>
<p className="settings-description">{t("settings.globalModels.globalBaselineModelsForEachAIRoleProject", " Global baseline models for each AI role. Project settings can override these per-project. ")}</p>
{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 (<div className="form-group" key={`global-${lane.laneId}`}>
<label htmlFor={`global-${lane.laneId}-model`}>{lane.label}</label>
<CustomModelDropdown id={`global-${lane.laneId}-model`} label={lane.label} models={availableModels} value={value} onChange={(selected) => {
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}/>
<small>{lane.helperText}</small>
</div>
);
})}
</>
)}
</div>);
})}
</>)}
{/* --- Startup Model Sync --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Startup Model Sync</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalModels.startupModelSync", "Startup Model Sync")}</h4>
<div className="form-group">
<label htmlFor="openrouterModelSync" className="checkbox-label">
<input
id="openrouterModelSync"
type="checkbox"
checked={form.openrouterModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}
/>
Sync OpenRouter model list at startup
</label>
<small>
When enabled, startup fetches the latest available models from the OpenRouter API so
model pickers always include the newest catalog.
</small>
<input id="openrouterModelSync" type="checkbox" checked={form.openrouterModelSync !== false} onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpenRouterModelListAtStartup", " Sync OpenRouter model list at startup ")}</label>
<small>{t("settings.globalModels.whenEnabledStartupFetchesTheLatestAvailableModels", " When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. ")}</small>
</div>
<div className="form-group">
<label htmlFor="opencodeGoModelSync" className="checkbox-label">
<input
id="opencodeGoModelSync"
type="checkbox"
checked={form.opencodeGoModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}
/>
Sync opencode-go model list at startup
</label>
<small>
When enabled, startup refreshes models through the local <code>opencode models opencode --refresh</code>
flow and publishes them under the opencode-go provider in model pickers.
</small>
<input id="opencodeGoModelSync" type="checkbox" checked={form.opencodeGoModelSync !== false} onChange={(e) => setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpencodeGoModelListAtStartup", " Sync opencode-go model list at startup ")}</label>
<small>{t("settings.globalModels.whenEnabledStartupRefreshesModelsThroughTheLocal", " When enabled, startup refreshes models through the local ")}<code>opencode models opencode --refresh</code>{t("settings.globalModels.flowAndPublishesThemUnderTheOpencodeGo", " flow and publishes them under the opencode-go provider in model pickers. ")}</small>
</div>
<details>
<summary>OpenRouter advanced</summary>
<summary>{t("settings.globalModels.openRouterAdvanced", "OpenRouter advanced")}</summary>
<div className="form-group">
<label htmlFor="openrouterAppAttributionReferer">OpenRouter HTTP-Referer</label>
<input
id="openrouterAppAttributionReferer"
className="input"
placeholder="https://runfusion.ai"
value={form.openrouterAppAttribution?.referer ?? ""}
onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
<label htmlFor="openrouterAppAttributionReferer">{t("settings.globalModels.openRouterHTTPReferer", "OpenRouter HTTP-Referer")}</label>
<input id="openrouterAppAttributionReferer" className="input" placeholder={t("settings.globalModels.httpsRunfusionAi", "https://runfusion.ai")} value={form.openrouterAppAttribution?.referer ?? ""} onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
referer: e.target.value,
},
}))}
/>
<small>Leave empty to omit this header. Default: https://runfusion.ai.</small>
},
}))}/>
<small>{t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultHttps", "Leave empty to omit this header. Default: https://runfusion.ai.")}</small>
</div>
<div className="form-group">
<label htmlFor="openrouterAppAttributionTitle">OpenRouter X-Title</label>
<input
id="openrouterAppAttributionTitle"
className="input"
placeholder="Fusion"
value={form.openrouterAppAttribution?.title ?? ""}
onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
<label htmlFor="openrouterAppAttributionTitle">{t("settings.globalModels.openRouterXTitle", "OpenRouter X-Title")}</label>
<input id="openrouterAppAttributionTitle" className="input" placeholder={t("settings.globalModels.fusion", "Fusion")} value={form.openrouterAppAttribution?.title ?? ""} onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
title: e.target.value,
},
}))}
/>
<small>Leave empty to omit this header. Default: Fusion.</small>
},
}))}/>
<small>{t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultFusion", "Leave empty to omit this header. Default: Fusion.")}</small>
</div>
<div className="form-group">
<label htmlFor="openrouterModelFiltersSupportedParameters">OpenRouter supported_parameters filter</label>
<input
id="openrouterModelFiltersSupportedParameters"
className="input"
placeholder="tools, structured_outputs"
value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
<label htmlFor="openrouterModelFiltersSupportedParameters">{t("settings.globalModels.openRouterSupportedParametersFilter", "OpenRouter supported_parameters filter")}</label>
<input id="openrouterModelFiltersSupportedParameters" className="input" placeholder={t("settings.globalModels.toolsStructuredOutputs", "tools, structured_outputs")} value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)} onChange={(e) => {
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,
},
}));
}}
/>
<small>Comma-separated values sent to OpenRouter model sync.</small>
}));
}}/>
<small>{t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync", "Comma-separated values sent to OpenRouter model sync.")}</small>
</div>
<div className="form-group">
<label htmlFor="openrouterModelFiltersOutputModalities">OpenRouter output_modalities filter</label>
<input
id="openrouterModelFiltersOutputModalities"
className="input"
placeholder="text"
value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
<label htmlFor="openrouterModelFiltersOutputModalities">{t("settings.globalModels.openRouterOutputModalitiesFilter", "OpenRouter output_modalities filter")}</label>
<input id="openrouterModelFiltersOutputModalities" className="input" placeholder={t("settings.globalModels.text", "text")} value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)} onChange={(e) => {
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,
},
}));
}}
/>
<small>Comma-separated values sent to OpenRouter model sync.</small>
}));
}}/>
<small>{t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync", "Comma-separated values sent to OpenRouter model sync.")}</small>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesOrder">OpenRouter routing order</label>
<input
id="openrouterProviderPreferencesOrder"
className="input"
placeholder="openai, anthropic"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
<label htmlFor="openrouterProviderPreferencesOrder">{t("settings.globalModels.openRouterRoutingOrder", "OpenRouter routing order")}</label>
<input id="openrouterProviderPreferencesOrder" className="input" placeholder={t("settings.globalModels.openaiAnthropic", "openai, anthropic")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)} onChange={(e) => {
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,
},
}));
}}
/>
}));
}}/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesIgnore">OpenRouter routing ignore</label>
<input
id="openrouterProviderPreferencesIgnore"
className="input"
placeholder="provider-name"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
<label htmlFor="openrouterProviderPreferencesIgnore">{t("settings.globalModels.openRouterRoutingIgnore", "OpenRouter routing ignore")}</label>
<input id="openrouterProviderPreferencesIgnore" className="input" placeholder={t("settings.globalModels.providerName", "provider-name")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)} onChange={(e) => {
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,
},
}));
}}
/>
}));
}}/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesOnly">OpenRouter routing only</label>
<input
id="openrouterProviderPreferencesOnly"
className="input"
placeholder="provider-name"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
<label htmlFor="openrouterProviderPreferencesOnly">{t("settings.globalModels.openRouterRoutingOnly", "OpenRouter routing only")}</label>
<input id="openrouterProviderPreferencesOnly" className="input" placeholder={t("settings.globalModels.providerName", "provider-name")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)} onChange={(e) => {
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,
},
}));
}}
/>
}));
}}/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesAllowFallbacks">OpenRouter allow fallbacks</label>
<select
id="openrouterProviderPreferencesAllowFallbacks"
className="select"
value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
<label htmlFor="openrouterProviderPreferencesAllowFallbacks">{t("settings.globalModels.openRouterAllowFallbacks", "OpenRouter allow fallbacks")}</label>
<select id="openrouterProviderPreferencesAllowFallbacks" className="select" value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"} onChange={(e) => {
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",
},
}));
}}
>
<option value="default">default</option>
<option value="allow">allow</option>
<option value="deny">deny</option>
}));
}}>
<option value="default">{t("settings.globalModels.default2", "default")}</option>
<option value="allow">{t("settings.globalModels.allow", "allow")}</option>
<option value="deny">{t("settings.globalModels.deny", "deny")}</option>
</select>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesSort">OpenRouter routing sort</label>
<select
id="openrouterProviderPreferencesSort"
className="select"
value={form.openrouterProviderPreferences?.sort ?? "default"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
<label htmlFor="openrouterProviderPreferencesSort">{t("settings.globalModels.openRouterRoutingSort", "OpenRouter routing sort")}</label>
<select id="openrouterProviderPreferencesSort" className="select" value={form.openrouterProviderPreferences?.sort ?? "default"} onChange={(e) => {
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",
},
}));
}}
>
<option value="default">default</option>
<option value="price">price</option>
<option value="throughput">throughput</option>
<option value="latency">latency</option>
}));
}}>
<option value="default">{t("settings.globalModels.default2", "default")}</option>
<option value="price">{t("settings.globalModels.price", "price")}</option>
<option value="throughput">{t("settings.globalModels.throughput", "throughput")}</option>
<option value="latency">{t("settings.globalModels.latency", "latency")}</option>
</select>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesRequireParameters" className="checkbox-label">
<input
id="openrouterProviderPreferencesRequireParameters"
type="checkbox"
checked={form.openrouterProviderPreferences?.require_parameters === true}
onChange={(e) => setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
require_parameters: e.target.checked,
},
}))}
/>
Require parameters
</label>
<input id="openrouterProviderPreferencesRequireParameters" type="checkbox" checked={form.openrouterProviderPreferences?.require_parameters === true} onChange={(e) => setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
require_parameters: e.target.checked,
},
}))}/>{t("settings.globalModels.requireParameters", " Require parameters ")}</label>
</div>
</details>
</>
);
</>);
}
export default GlobalModelsSection;

View File

@@ -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<MemoryFileInfo["layer"], string> = {
"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<MemoryFileInfo["layer"], string> = {
"long-term": "Long-term",
daily: "Daily",
dreams: "Dreams",
};
return (<>
{scopeBanner}
<h4 className="settings-section-heading">Memory</h4>
<h4 className="settings-section-heading">{t("settings.memory.memory", "Memory")}</h4>
<div className="form-group">
<small className="settings-muted">
Memory lives in <code>.fusion/memory/</code>. Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed.
</small>
<small className="settings-muted">{t("settings.memory.memoryLivesIn", " Memory lives in ")}<code>.fusion/memory/</code>{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. ")}</small>
</div>
<div className="form-group">
<label htmlFor="memoryEnabled" className="checkbox-label">
<input
id="memoryEnabled"
type="checkbox"
checked={form.memoryEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))
}
/>
Enable memory tools
</label>
<small>Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.</small>
<input id="memoryEnabled" type="checkbox" checked={form.memoryEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))}/>{t("settings.memory.enableMemoryTools", " Enable memory tools ")}</label>
<small>{t("settings.memory.agentsGetMemorySearchMemoryGetAndMemory", "Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.")}</small>
</div>
{backendLoading ? (
<div className="form-group">
<small className="settings-muted">Checking memory write access...</small>
</div>
) : backendError ? (
<div className="form-group">
<small className="field-error">Failed to load backend status: {backendError}</small>
</div>
) : null}
{backendLoading ? (<div className="form-group">
<small className="settings-muted">{t("settings.memory.checkingMemoryWriteAccess", "Checking memory write access...")}</small>
</div>) : backendError ? (<div className="form-group">
<small className="field-error">{t("settings.memory.failedToLoadBackendStatus", "Failed to load backend status: ")}{backendError}</small>
</div>) : null}
{backendStatusResolved && backendStatus.qmdAvailable === false && (
<div className="settings-empty-state memory-status-message">
<span>
qmd is not installed. Search will use local files.
Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
{backendStatusResolved && backendStatus.qmdAvailable === false && (<div className="settings-empty-state memory-status-message">
<span>{t("settings.memory.qmdIsNotInstalledSearchWillUseLocal", " qmd is not installed. Search will use local files. Install indexed retrieval: ")}<code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
</span>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onInstallQmd}
disabled={qmdInstallLoading}
>
<button type="button" className="btn btn-secondary btn-sm" onClick={onInstallQmd} disabled={qmdInstallLoading}>
{qmdInstallLoading ? t("settings.memory.installing", "Installing…") : t("settings.memory.installQmd", "Install qmd")}
</button>
</div>
)}
</div>)}
<div className="form-group">
<label htmlFor="memoryAutoSummarizeEnabled" className="checkbox-label">
<input
id="memoryAutoSummarizeEnabled"
type="checkbox"
checked={form.memoryAutoSummarizeEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))
}
/>
Auto-Summarize Memory
</label>
<small>Automatically compact memory when it exceeds the threshold on a schedule</small>
<input id="memoryAutoSummarizeEnabled" type="checkbox" checked={form.memoryAutoSummarizeEnabled || false} onChange={(e) => setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))}/>{t("settings.memory.autoSummarizeMemory", " Auto-Summarize Memory ")}</label>
<small>{t("settings.memory.automaticallyCompactMemoryWhenItExceedsTheThreshold", "Automatically compact memory when it exceeds the threshold on a schedule")}</small>
</div>
{(form.memoryAutoSummarizeEnabled || false) && (
<>
{(form.memoryAutoSummarizeEnabled || false) && (<>
<div className="form-group">
<label htmlFor="memoryAutoSummarizeThresholdChars">Compaction Threshold (chars)</label>
<input
id="memoryAutoSummarizeThresholdChars"
type="number"
className="input"
value={form.memoryAutoSummarizeThresholdChars ?? 50000}
onChange={(e) =>
setForm((f) => ({
...f,
memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000,
}))
}
min={1000}
/>
<small>Memory will be compacted when it exceeds this character count</small>
<label htmlFor="memoryAutoSummarizeThresholdChars">{t("settings.memory.compactionThresholdChars", "Compaction Threshold (chars)")}</label>
<input id="memoryAutoSummarizeThresholdChars" type="number" className="input" value={form.memoryAutoSummarizeThresholdChars ?? 50000} onChange={(e) => setForm((f) => ({
...f,
memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000,
}))} min={1000}/>
<small>{t("settings.memory.memoryWillBeCompactedWhenItExceedsThis", "Memory will be compacted when it exceeds this character count")}</small>
</div>
<div className="form-group">
<label htmlFor="memoryAutoSummarizeSchedule">Schedule (cron)</label>
<input
id="memoryAutoSummarizeSchedule"
type="text"
className="input"
value={form.memoryAutoSummarizeSchedule ?? "0 3 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))
}
placeholder="0 3 * * *"
/>
<small>Cron expression for auto-summarize schedule (default: daily at 3 AM)</small>
<label htmlFor="memoryAutoSummarizeSchedule">{t("settings.memory.scheduleCron", "Schedule (cron)")}</label>
<input id="memoryAutoSummarizeSchedule" type="text" className="input" value={form.memoryAutoSummarizeSchedule ?? "0 3 * * *"} onChange={(e) => setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))} placeholder={t("settings.memory.03", "0 3 * * *")}/>
<small>{t("settings.memory.cronExpressionForAutoSummarizeScheduleDefaultDaily", "Cron expression for auto-summarize schedule (default: daily at 3 AM)")}</small>
</div>
</>
)}
</>)}
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }}/>
<div className="form-group">
<label htmlFor="memoryDreamsEnabled" className="checkbox-label">
<input
id="memoryDreamsEnabled"
type="checkbox"
checked={form.memoryDreamsEnabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))
}
disabled={!isMemoryEnabled}
/>
Process dreams from daily memory
</label>
<small>Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.</small>
<input id="memoryDreamsEnabled" type="checkbox" checked={form.memoryDreamsEnabled === true} onChange={(e) => setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))} disabled={!isMemoryEnabled}/>{t("settings.memory.processDreamsFromDailyMemory", " Process dreams from daily memory ")}</label>
<small>{t("settings.memory.turnsDailyNotesIntoDREAMSMdAndPromotes", "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.")}</small>
</div>
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
<>
{isMemoryEnabled && form.memoryDreamsEnabled === true && (<>
<div className="form-group">
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
<input
id="memoryDreamsSchedule"
type="text"
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
onChange={(e) =>
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
}
/>
<small>Cron expression for dream processing.</small>
<label htmlFor="memoryDreamsSchedule">{t("settings.memory.dreamSchedule", "Dream Schedule")}</label>
<input id="memoryDreamsSchedule" type="text" value={form.memoryDreamsSchedule ?? "0 4 * * *"} onChange={(e) => setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))}/>
<small>{t("settings.memory.cronExpressionForDreamProcessing", "Cron expression for dream processing.")}</small>
</div>
<div className="form-group">
<button
type="button"
className="btn btn-sm"
onClick={onDreamNow}
disabled={dreamRunning || form.memoryDreamsEnabled !== true}
>
{dreamRunning ? (
<>
<Loader2 size={14} className="animate-spin" />
Dreaming…
</>
) : (
t("settings.memory.dreamNow", "Dream Now")
)}
<button type="button" className="btn btn-sm" onClick={onDreamNow} disabled={dreamRunning || form.memoryDreamsEnabled !== true}>
{dreamRunning ? (<>
<Loader2 size={14} className="animate-spin"/>{t("settings.memory.dreaming", " Dreaming\u2026 ")}</>) : (t("settings.memory.dreamNow", "Dream Now"))}
</button>
<small>Manually trigger dream processing now.</small>
<small>{t("settings.memory.manuallyTriggerDreamProcessingNow", "Manually trigger dream processing now.")}</small>
</div>
</>
)}
</>)}
<div className="memory-retrieval-test">
<div className="form-group">
<label htmlFor="memoryRetrievalQuery">Test Retrieval</label>
<input
id="memoryRetrievalQuery"
type="text"
value={memoryTestQuery}
onChange={(e) => setMemoryTestQuery(e.target.value)}
placeholder="Search memory with qmd"
/>
<small>Runs the same qmd-backed memory_search path agents use.</small>
<label htmlFor="memoryRetrievalQuery">{t("settings.memory.testRetrieval", "Test Retrieval")}</label>
<input id="memoryRetrievalQuery" type="text" value={memoryTestQuery} onChange={(e) => setMemoryTestQuery(e.target.value)} placeholder={t("settings.memory.searchMemoryWithQmd", "Search memory with qmd")}/>
<small>{t("settings.memory.runsTheSameQmdBackedMemorySearchPath", "Runs the same qmd-backed memory_search path agents use.")}</small>
</div>
<div className="form-group">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onTestMemoryRetrieval}
disabled={memoryTestLoading}
>
<button type="button" className="btn btn-secondary btn-sm" onClick={onTestMemoryRetrieval} disabled={memoryTestLoading}>
{memoryTestLoading ? t("settings.memory.testing", "Testing…") : t("settings.memory.testRetrieval", "Test Retrieval")}
</button>
</div>
{memoryTestResult && (
<div className="memory-test-result">
{memoryTestResult && (<div className="memory-test-result">
<strong>
{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}"
</strong>
<small>
qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"}
<small>{t("settings.memory.qmd", " qmd ")}{memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"}
</small>
{memoryTestResult.results.length > 0 ? (
<ul>
{memoryTestResult.results.map((result, index) => (
<li key={`${result.path}-${result.lineStart}-${index}`}>
{memoryTestResult.results.length > 0 ? (<ul>
{memoryTestResult.results.map((result, index) => (<li key={`${result.path}-${result.lineStart}-${index}`}>
<span>{result.path}:{result.lineStart}</span>
<p>{result.snippet}</p>
</li>
))}
</ul>
) : (
<small>No matching memory found.</small>
)}
</div>
)}
</li>))}
</ul>) : (<small>{t("settings.memory.noMatchingMemoryFound", "No matching memory found.")}</small>)}
</div>)}
</div>
{!isMemoryEnabled && (
<div className="settings-empty-state memory-status-message">
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
</div>
)}
{isMemoryEnabled && backendStatusResolved && !isBackendWritable && (
<div className="settings-empty-state memory-status-message">
Memory is configured with a read-only backend. You can view the file, but saving is disabled.
</div>
)}
{!isMemoryEnabled && (<div className="settings-empty-state memory-status-message">{t("settings.memory.memoryIsCurrentlyDisabledYouCanViewThe", " Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled. ")}</div>)}
{isMemoryEnabled && backendStatusResolved && !isBackendWritable && (<div className="settings-empty-state memory-status-message">{t("settings.memory.memoryIsConfiguredWithAReadOnlyBackend", " Memory is configured with a read-only backend. You can view the file, but saving is disabled. ")}</div>)}
{memoryLoading ? (
<div className="settings-empty-state">Loading memory…</div>
) : (
<div className="memory-editor-section">
{memoryLoading ? (<div className="settings-empty-state">{t("settings.memory.loadingMemory", "Loading memory\u2026")}</div>) : (<div className="memory-editor-section">
<div className="form-group">
<label htmlFor="memoryFilePath">Memory File</label>
<select
id="memoryFilePath"
value={selectedMemoryPath}
onChange={(e) => {
<label htmlFor="memoryFilePath">{t("settings.memory.memoryFile", "Memory File")}</label>
<select id="memoryFilePath" value={selectedMemoryPath} onChange={(e) => {
setSelectedMemoryPath(e.target.value);
setMemoryDirty(false);
}}
disabled={memoryDirty}
>
{memoryFiles.map((file) => (
<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
}} disabled={memoryDirty}>
{memoryFiles.map((file) => (<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
{formatMemoryFileOptionLabel(file)}
</option>
))}
</option>))}
</select>
<small>
{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."}
</small>
</div>
{selectedMemoryFile && (
<div className="memory-file-summary">
{selectedMemoryFile && (<div className="memory-file-summary">
<span>{memoryLayerNames[selectedMemoryFile.layer]}</span>
<strong>{selectedMemoryFile.path}</strong>
<small>
{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()}
</small>
</div>
)}
</div>)}
<div className="form-group memory-editor-form-group">
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
<small>
@@ -374,56 +197,33 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect
{!selectedMemoryFile && "Edits the selected memory file."}
</small>
<div className="memory-editor-frame">
<FileEditor
content={memoryContent}
onChange={(content) => {
setMemoryContent(content);
setMemoryDirty(true);
}}
readOnly={!isEditingAllowed}
filePath={selectedMemoryPath}
/>
<FileEditor content={memoryContent} onChange={(content) => {
setMemoryContent(content);
setMemoryDirty(true);
}} readOnly={!isEditingAllowed} filePath={selectedMemoryPath}/>
</div>
</div>
</div>
)}
</div>)}
{!memoryLoading && (
<div className="form-group">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onCompactMemory}
disabled={!isEditingAllowed || memoryDirty || memoryCompactLoading}
>
{!memoryLoading && (<div className="form-group">
<button type="button" className="btn btn-secondary btn-sm" onClick={onCompactMemory} disabled={!isEditingAllowed || memoryDirty || memoryCompactLoading}>
{memoryCompactLoading ? t("settings.memory.compacting", "Compacting…") : t("settings.memory.compactSelectedFile", "Compact Selected File")}
</button>
<small>
{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.`}
</small>
</div>
)}
</div>)}
{memoryDirty && isEditingAllowed && (
<div className="form-group">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onSaveMemory}
>
{memoryDirty && isEditingAllowed && (<div className="form-group">
<button type="button" className="btn btn-primary btn-sm" onClick={onSaveMemory}>
{t("settings.memory.saveMemory", "Save Memory")}
</button>
</div>
)}
{memoryDirty && !isEditingAllowed && (
<div className="form-group">
<small className="field-error">Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}</small>
</div>
)}
</>
);
</div>)}
{memoryDirty && !isEditingAllowed && (<div className="form-group">
<small className="field-error">{t("settings.memory.cannotSave", "Cannot save: ")}{isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}</small>
</div>)}
</>);
}
export default MemorySection;

View File

@@ -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<LegacyAutoMergeStampListResponse> {
if (!response.ok) {
throw new Error(await response.text() || "Failed to load legacy auto-merge stamps");
}
return response.json() as Promise<LegacyAutoMergeStampListResponse>;
if (!response.ok) {
throw new Error(await response.text() || "Failed to load legacy auto-merge stamps");
}
return response.json() as Promise<LegacyAutoMergeStampListResponse>;
}
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<LegacyAutoMergeStampCandidate[]>([]);
const [legacyStampLoading, setLegacyStampLoading] = useState(true);
const [legacyStampApplying, setLegacyStampApplying] = useState(false);
const [legacyStampError, setLegacyStampError] = useState<string | null>(null);
const [legacyStampSuccess, setLegacyStampSuccess] = useState<string | null>(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<LegacyAutoMergeStampCandidate[]>([]);
const [legacyStampLoading, setLegacyStampLoading] = useState(true);
const [legacyStampApplying, setLegacyStampApplying] = useState(false);
const [legacyStampError, setLegacyStampError] = useState<string | null>(null);
const [legacyStampSuccess, setLegacyStampSuccess] = useState<string | null>(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}
<h4 className="settings-section-heading">Merge</h4>
<h4 className="settings-section-heading">{t("settings.merge.merge", "Merge")}</h4>
<div className="form-group">
<label htmlFor="autoMerge" className="checkbox-label">
<input
id="autoMerge"
type="checkbox"
checked={form.autoMerge}
onChange={(e) =>
setForm((f) => ({ ...f, autoMerge: e.target.checked }))
}
/>
Auto-merge completed tasks
</label>
<input id="autoMerge" type="checkbox" checked={form.autoMerge} onChange={(e) => setForm((f) => ({ ...f, autoMerge: e.target.checked }))}/>{t("settings.merge.autoMergeCompletedTasks", " Auto-merge completed tasks ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.whenEnabledTasksThatPassReviewAreAutomatically", "When enabled, tasks that pass review are automatically merged into the main branch")}</small>
</details>
</div>
<div className="form-group">
<label htmlFor="maxAutoMergeRetries">Auto-merge conflict retries</label>
<label htmlFor="maxAutoMergeRetries">{t("settings.merge.autoMergeConflictRetries", "Auto-merge conflict retries")}</label>
{/*
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.
*/}
<input
id="maxAutoMergeRetries"
type="number"
min={1}
step={1}
value={form.maxAutoMergeRetries ?? 3}
onChange={(e) =>
setForm((f) => ({
...f,
maxAutoMergeRetries: e.target.value === "" ? undefined : resolveMaxAutoMergeRetriesForMergeForm(e.target.value),
}))
}
/>
<small>Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.</small>
<input id="maxAutoMergeRetries" type="number" min={1} step={1} value={form.maxAutoMergeRetries ?? 3} onChange={(e) => setForm((f) => ({
...f,
maxAutoMergeRetries: e.target.value === "" ? undefined : resolveMaxAutoMergeRetriesForMergeForm(e.target.value),
}))}/>
<small>{t("settings.merge.positiveIntegerRetryCapForAutoMergeConflict", "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.")}</small>
</div>
<div className="form-group" data-testid="legacy-automerge-stamp-cleanup-panel">
<h5 className="settings-section-heading">Legacy auto-merge stamp cleanup</h5>
<small>
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.
</small>
{legacyStampLoading ? (
<small aria-live="polite">Checking for legacy auto-merge stamps…</small>
) : legacyStampCandidates.length === 0 ? (
<small data-testid="legacy-automerge-stamp-empty-state">
No legacy auto-merge stamps to clean up.
</small>
) : (
<>
<small>{legacyStampCandidates.length} legacy auto-merge stamp{legacyStampCandidates.length === 1 ? "" : "s"} ready to clean up.</small>
<h5 className="settings-section-heading">{t("settings.merge.legacyAutoMergeStampCleanup", "Legacy auto-merge stamp cleanup")}</h5>
<small>{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. ")}</small>
{legacyStampLoading ? (<small aria-live="polite">{t("settings.merge.checkingForLegacyAutoMergeStamps", "Checking for legacy auto-merge stamps\u2026")}</small>) : legacyStampCandidates.length === 0 ? (<small data-testid="legacy-automerge-stamp-empty-state">{t("settings.merge.noLegacyAutoMergeStampsToCleanUp", " No legacy auto-merge stamps to clean up. ")}</small>) : (<>
<small>{legacyStampCandidates.length}{t("settings.merge.legacyAutoMergeStamp", " legacy auto-merge stamp")}{legacyStampCandidates.length === 1 ? "" : "s"}{t("settings.merge.readyToCleanUp", " ready to clean up.")}</small>
<ul>
{legacyStampCandidates.map((candidate) => (
<li key={candidate.taskId} data-testid="legacy-automerge-stamp-candidate-row">
{legacyStampCandidates.map((candidate) => (<li key={candidate.taskId} data-testid="legacy-automerge-stamp-candidate-row">
<strong>{candidate.taskId}</strong> — {candidate.column}
</li>
))}
</li>))}
</ul>
<button
type="button"
className="btn"
onClick={applyLegacyAutoMergeStampCleanup}
disabled={legacyStampApplying}
data-testid="legacy-automerge-stamp-apply-button"
>
<button type="button" className="btn" onClick={applyLegacyAutoMergeStampCleanup} disabled={legacyStampApplying} data-testid="legacy-automerge-stamp-apply-button">
{legacyStampApplying ? "Applying cleanup…" : "Apply cleanup"}
</button>
</>
)}
</>)}
{legacyStampSuccess ? <small className="settings-success" aria-live="polite">{legacyStampSuccess}</small> : null}
{legacyStampError ? <small className="settings-error" role="alert">{legacyStampError}</small> : null}
</div>
<div className="form-group">
<label htmlFor="mergerMode">AI merge</label>
<select
id="mergerMode"
className="select"
value={form.merger?.mode ?? "ai"}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))
}
>
<option value="ai">AI merge (default) — AI merges in a clean room, an AI reviewer audits with retries, then lands</option>
<option value="deterministic">Deterministic (legacy) — rebase / conflict-strategy / audit pipeline</option>
<label htmlFor="mergerMode">{t("settings.merge.aIMerge", "AI merge")}</label>
<select id="mergerMode" className="select" value={form.merger?.mode ?? "ai"} onChange={(e) => setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))}>
<option value="ai">{t("settings.merge.aIMergeDefaultAIMergesInAClean", "AI merge (default) \u2014 AI merges in a clean room, an AI reviewer audits with retries, then lands")}</option>
<option value="deterministic">{t("settings.merge.deterministicLegacyRebaseConflictStrategyAuditPipeline", "Deterministic (legacy) \u2014 rebase / conflict-strategy / audit pipeline")}</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
AI mode merges the task branch into an isolated clean-room checkout at the target
branch&apos;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. <strong>The legacy merge settings below do not
apply while AI merge is on.</strong>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.aIModeMergesTheTaskBranchIntoAn", " AI mode merges the task branch into an isolated clean-room checkout at the target branch&apos;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. ")}<strong>{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}</strong>
</small>
</details>
</div>
{(form.merger?.mode ?? "ai") === "ai" && (
<>
{(form.merger?.mode ?? "ai") === "ai" && (<>
<div className="form-group">
<label htmlFor="mergerMaxReviewPasses">Max AI review passes</label>
<input
id="mergerMaxReviewPasses"
type="number"
min={0}
max={10}
value={form.merger?.maxReviewPasses ?? 3}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))
}
/>
<small>AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project&apos;s reviewer/validator model.</small>
<label htmlFor="mergerMaxReviewPasses">{t("settings.merge.maxAIReviewPasses", "Max AI review passes")}</label>
<input id="mergerMaxReviewPasses" type="number" min={0} max={10} value={form.merger?.maxReviewPasses ?? 3} onChange={(e) => setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))}/>
<small>{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&apos;s reviewer/validator model.")}</small>
</div>
<div className="form-group">
<label htmlFor="mergerAllowDirtyLocalCheckoutSync" className="checkbox-label">
<input
id="mergerAllowDirtyLocalCheckoutSync"
type="checkbox"
checked={form.merger?.allowDirtyLocalCheckoutSync === true}
onChange={(e) =>
setForm((f) => ({
...f,
merger: { ...(f.merger ?? {}), allowDirtyLocalCheckoutSync: e.target.checked },
}))
}
/>
Allow AI merge to sync a dirty checked-out integration branch
</label>
<input id="mergerAllowDirtyLocalCheckoutSync" type="checkbox" checked={form.merger?.allowDirtyLocalCheckoutSync === true} onChange={(e) => 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 ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
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.
</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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. ")}</small>
</details>
</div>
</>
)}
</>)}
<div className="form-group">
<label htmlFor="testMode" className="checkbox-label">
<input
id="testMode"
type="checkbox"
checked={form.testMode === true}
onChange={(e) =>
setForm((f) => ({ ...f, testMode: e.target.checked }))
}
/>
Enable test mode
</label>
<input id="testMode" type="checkbox" checked={form.testMode === true} onChange={(e) => setForm((f) => ({ ...f, testMode: e.target.checked }))}/>{t("settings.merge.enableTestMode", " Enable test mode ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.forcesAllAILanesToUseTheDeterministic", "Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.")}</small>
</details>
</div>
<MovedSettingsStub
message={t(
"settings.movedStub.reviewVerification",
"Review, verification auto-fix, and scope-enforcement settings now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
<MovedSettingsStub message={t("settings.movedStub.reviewVerification", "Review, verification auto-fix, and scope-enforcement settings now live on the workflow.")} onOpenWorkflowSettings={onOpenWorkflowSettings}/>
<div className="form-group">
<label htmlFor="mergeStrategy">Auto-completion mode</label>
<select
id="mergeStrategy"
value={form.mergeStrategy || "direct"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeStrategy: e.target.value as Settings["mergeStrategy"] }))
}
>
<option value="direct">Direct merge into the current branch</option>
<option value="pull-request">Create, monitor, and merge a GitHub pull request</option>
<label htmlFor="mergeStrategy">{t("settings.merge.autoCompletionMode", "Auto-completion mode")}</label>
<select id="mergeStrategy" value={form.mergeStrategy || "direct"} onChange={(e) => setForm((f) => ({ ...f, mergeStrategy: e.target.value as Settings["mergeStrategy"] }))}>
<option value="direct">{t("settings.merge.directMergeIntoTheCurrentBranch", "Direct merge into the current branch")}</option>
<option value="pull-request">{t("settings.merge.createMonitorAndMergeAGitHubPullRequest", "Create, monitor, and merge a GitHub pull request")}</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
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.
</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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. ")}</small>
</details>
</div>
<div className="form-group">
<label htmlFor="integrationBranch">Integration branch</label>
<label htmlFor="integrationBranch">{t("settings.merge.integrationBranch", "Integration branch")}</label>
{(() => {
const currentValue = form.integrationBranch ?? "";
const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue);
const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown);
if (isCustomMode) {
return (
<div className="form-inline-group">
<input
id="integrationBranch"
type="text"
className="input"
placeholder="branch name"
value={currentValue}
onChange={(e) => {
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 (<div className="form-inline-group">
<input id="integrationBranch" type="text" className="input" placeholder={t("settings.merge.branchName", "branch name")} value={currentValue} onChange={(e) => {
const trimmed = e.target.value.trim();
setForm((f) => ({
...f,
integrationBranch: trimmed.length === 0 ? undefined : trimmed,
}));
}} data-testid="integration-branch-custom-input"/>
<button type="button" className="btn-link" onClick={() => {
setIntegrationBranchCustomMode(false);
setForm((f) => ({ ...f, integrationBranch: undefined }));
}} data-testid="integration-branch-use-dropdown">{t("settings.merge.useDropdown", " Use dropdown ")}</button>
</div>);
}
const CUSTOM = "__fusion-custom__";
const AUTO = "";
return (<select id="integrationBranch" className="select" value={currentValue} onChange={(e) => {
const next = e.target.value;
if (next === CUSTOM) {
setIntegrationBranchCustomMode(true);
return;
}
setForm((f) => ({
...f,
integrationBranch: trimmed.length === 0 ? undefined : trimmed,
...f,
integrationBranch: next === AUTO ? undefined : next,
}));
}}
data-testid="integration-branch-custom-input"
/>
<button
type="button"
className="btn-link"
onClick={() => {
setIntegrationBranchCustomMode(false);
setForm((f) => ({ ...f, integrationBranch: undefined }));
}}
data-testid="integration-branch-use-dropdown"
>
Use dropdown
</button>
</div>
);
}
const CUSTOM = "__fusion-custom__";
const AUTO = "";
return (
<select
id="integrationBranch"
className="select"
value={currentValue}
onChange={(e) => {
const next = e.target.value;
if (next === CUSTOM) {
setIntegrationBranchCustomMode(true);
return;
}
setForm((f) => ({
...f,
integrationBranch: next === AUTO ? undefined : next,
}));
}}
data-testid="integration-branch-select"
>
<option value={AUTO}>(auto-detect — origin/HEAD → main)</option>
{integrationBranchOptions.map((name) => (
<option key={name} value={name}>{name}</option>
))}
<option value={CUSTOM}>Custom…</option>
</select>
);
}} data-testid="integration-branch-select">
<option value={AUTO}>{t("settings.merge.autoDetectOriginHEADMain", "(auto-detect \u2014 origin/HEAD \u2192 main)")}</option>
{integrationBranchOptions.map((name) => (<option key={name} value={name}>{name}</option>))}
<option value={CUSTOM}>{t("settings.merge.custom", "Custom\u2026")}</option>
</select>);
})()}
<details className="settings-option-details">
<summary>More details</summary>
<small>
The canonical branch Fusion merges tasks into and uses as the reference for all
ahead/behind / overlap / pre-rebase computations. Leave on <em>auto-detect</em>
to resolve via the standard cascade
(<code>integrationBranch</code> → legacy <code>baseBranch</code> →
<code>origin/HEAD</code> symbolic ref → fallback <code>main</code>). Pick a
local branch from the dropdown — common integration names like <code>main</code>,
<code>master</code>, <code>trunk</code>, and <code>develop</code> are listed
first — or choose <em>Custom…</em> to type a branch that doesn&apos;t exist
locally yet. Applies to both direct merges and pull-request mode; individual
tasks can still override via task metadata.
</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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 ")}<em>{t("settings.merge.autoDetect", "auto-detect")}</em>{t("settings.merge.toResolveViaTheStandardCascade", " to resolve via the standard cascade (")}<code>integrationBranch</code>{t("settings.merge.legacy", " \u2192 legacy ")}<code>baseBranch</code> →
<code>origin/HEAD</code>{t("settings.merge.symbolicRefFallback", " symbolic ref \u2192 fallback ")}<code>main</code>{t("settings.merge.pickALocalBranchFromTheDropdownCommon", "). Pick a local branch from the dropdown \u2014 common integration names like ")}<code>main</code>,
<code>master</code>, <code>trunk</code>{t("settings.merge.and", ", and ")}<code>develop</code>{t("settings.merge.areListedFirstOrChoose", " are listed first \u2014 or choose ")}<em>{t("settings.merge.custom", "Custom\u2026")}</em>{t("settings.merge.toTypeABranchThatDoesnAposT", " to type a branch that doesn&apos;t exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ")}</small>
</details>
</div>
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (
<>
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (<>
<div className="form-group">
<label htmlFor="directMergeCommitStrategy">Direct merge commit routing</label>
<select
id="directMergeCommitStrategy"
className="select"
value={form.directMergeCommitStrategy ?? "always-squash"}
onChange={(e) =>
setForm((f) => ({
...f,
directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase",
}))
}
>
<option value="auto">Auto — squash single-substantive branches, preserve multi-substantive history</option>
<option value="always-squash">Always squash direct merges</option>
<option value="always-rebase">Always preserve direct-merge commit history</option>
<label htmlFor="directMergeCommitStrategy">{t("settings.merge.directMergeCommitRouting", "Direct merge commit routing")}</label>
<select id="directMergeCommitStrategy" className="select" value={form.directMergeCommitStrategy ?? "always-squash"} onChange={(e) => setForm((f) => ({
...f,
directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase",
}))}>
<option value="auto">{t("settings.merge.autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive", "Auto \u2014 squash single-substantive branches, preserve multi-substantive history")}</option>
<option value="always-squash">{t("settings.merge.alwaysSquashDirectMerges", "Always squash direct merges")}</option>
<option value="always-rebase">{t("settings.merge.alwaysPreserveDirectMergeCommitHistory", "Always preserve direct-merge commit history")}</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Auto keeps today&apos;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 <code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.autoKeepsTodayAposSSquashBehaviorFor", " Auto keeps today&apos;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 ")}<code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeIntegrationWorktree">Integration worktree</label>
<select
id="mergeIntegrationWorktree"
className="select"
value={form.mergeIntegrationWorktree ?? "reuse-task-worktree"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeIntegrationWorktree: e.target.value as Settings["mergeIntegrationWorktree"],
}))
}
>
<option value="reuse-task-worktree">Reuse task worktree (default)</option>
<option value="cwd-main">Use project root (legacy)</option>
<label htmlFor="mergeIntegrationWorktree">{t("settings.merge.integrationWorktree", "Integration worktree")}</label>
<select id="mergeIntegrationWorktree" className="select" value={form.mergeIntegrationWorktree ?? "reuse-task-worktree"} onChange={(e) => setForm((f) => ({
...f,
mergeIntegrationWorktree: e.target.value as Settings["mergeIntegrationWorktree"],
}))}>
<option value="reuse-task-worktree">{t("settings.merge.reuseTaskWorktreeDefault", "Reuse task worktree (default)")}</option>
<option value="cwd-main">{t("settings.merge.useProjectRootLegacy", "Use project root (legacy)")}</option>
</select>
<small>
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.
</small>
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (
<div
className="settings-warning-banner"
role="alert"
aria-live="polite"
data-testid="merge-integration-worktree-warning"
>
<strong>Legacy integration-branch mode.</strong>{" "}
Auto-merge will run rebase, conflict resolution, and squash commits inside the
project root (the user&apos;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&apos;t, merges may fail or touch the user&apos;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).
</div>
)}
<small>{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. ")}</small>
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (<div className="settings-warning-banner" role="alert" aria-live="polite" data-testid="merge-integration-worktree-warning">
<strong>{t("settings.merge.legacyIntegrationBranchMode", "Legacy integration-branch mode.")}</strong>{" "}{t("settings.merge.autoMergeWillRunRebaseConflictResolutionAnd", " Auto-merge will run rebase, conflict resolution, and squash commits inside the project root (the user&apos;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&apos;t, merges may fail or touch the user&apos;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). ")}</div>)}
</div>
<div className="form-group">
<label htmlFor="mergeAdvanceAutoSync">Auto-sync project checkout after merge</label>
<select
id="mergeAdvanceAutoSync"
className="select"
value={form.mergeAdvanceAutoSync ?? "stash-and-ff"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeAdvanceAutoSync: e.target.value as "off" | "ff-only" | "stash-and-ff",
}))
}
data-testid="merge-advance-auto-sync-select"
>
<option value="stash-and-ff">Stash + fast-forward (default) — preserve local edits</option>
<option value="ff-only">Fast-forward only — skip dirty worktrees</option>
<option value="off">Off — leave the project root stale (legacy behavior)</option>
<label htmlFor="mergeAdvanceAutoSync">{t("settings.merge.autoSyncProjectCheckoutAfterMerge", "Auto-sync project checkout after merge")}</label>
<select id="mergeAdvanceAutoSync" className="select" value={form.mergeAdvanceAutoSync ?? "stash-and-ff"} onChange={(e) => setForm((f) => ({
...f,
mergeAdvanceAutoSync: e.target.value as "off" | "ff-only" | "stash-and-ff",
}))} data-testid="merge-advance-auto-sync-select">
<option value="stash-and-ff">{t("settings.merge.stashFastForwardDefaultPreserveLocalEdits", "Stash + fast-forward (default) \u2014 preserve local edits")}</option>
<option value="ff-only">{t("settings.merge.fastForwardOnlySkipDirtyWorktrees", "Fast-forward only \u2014 skip dirty worktrees")}</option>
<option value="off">{t("settings.merge.offLeaveTheProjectRootStaleLegacyBehavior", "Off \u2014 leave the project root stale (legacy behavior)")}</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
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). <code>Stash + fast-forward</code> 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. <code>Fast-forward only</code> snaps cleanly when the
worktree has no edits and skips otherwise. <code>Off</code> is the legacy
behavior: <code>git status</code> in your project root will show the new commits
inverted as &quot;staged changes&quot; until you pull manually. Only applies to direct
merges.
</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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). ")}<code>Stash + fast-forward</code>{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. ")}<code>Fast-forward only</code>{t("settings.merge.snapsCleanlyWhenTheWorktreeHasNoEdits", " snaps cleanly when the worktree has no edits and skips otherwise. ")}<code>Off</code>{t("settings.merge.isTheLegacyBehavior", " is the legacy behavior: ")}<code>git status</code>{t("settings.merge.inYourProjectRootWillShowTheNew", " in your project root will show the new commits inverted as &quot;staged changes&quot; until you pull manually. Only applies to direct merges. ")}</small>
</details>
</div>
</>
)}
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Authentication</h4>
</>)}
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.merge.gitHubAuthentication", "GitHub Authentication")}</h4>
<div className="form-group">
<label htmlFor="githubAuthMode">GitHub auth mode</label>
<select
id="githubAuthMode"
className="select"
value={form.githubAuthMode ?? "gh-cli"}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))
}
>
<option value="gh-cli">GitHub CLI (gh auth)</option>
<option value="token">Personal access token</option>
<label htmlFor="githubAuthMode">{t("settings.merge.gitHubAuthMode", "GitHub auth mode")}</label>
<select id="githubAuthMode" className="select" value={form.githubAuthMode ?? "gh-cli"} onChange={(e) => setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))}>
<option value="gh-cli">{t("settings.merge.gitHubCLIGhAuth", "GitHub CLI (gh auth)")}</option>
<option value="token">{t("settings.merge.personalAccessToken", "Personal access token")}</option>
</select>
</div>
{(form.githubAuthMode ?? "gh-cli") === "token" && (
<div className="form-group">
<label htmlFor="githubAuthToken">GitHub personal access token</label>
<input
id="githubAuthToken"
type="password"
className="input"
value={form.githubAuthToken ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))
}
/>
</div>
)}
{(form.githubAuthMode ?? "gh-cli") === "token" && (<div className="form-group">
<label htmlFor="githubAuthToken">{t("settings.merge.gitHubPersonalAccessToken", "GitHub personal access token")}</label>
<input id="githubAuthToken" type="password" className="input" value={form.githubAuthToken ?? ""} onChange={(e) => setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))}/>
</div>)}
<div className="form-group">
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
<input
id="includeTaskIdInCommit"
type="checkbox"
checked={form.includeTaskIdInCommit !== false}
onChange={(e) =>
setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))
}
/>
Include task ID in commit scope
</label>
<input id="includeTaskIdInCommit" type="checkbox" checked={form.includeTaskIdInCommit !== false} onChange={(e) => setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))}/>{t("settings.merge.includeTaskIDInCommitScope", " Include task ID in commit scope ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When disabled, merge commit messages omit the task ID from the scope (e.g. <code>feat: ...</code> instead of <code>feat(KB-001): ...</code>)</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.whenDisabledMergeCommitMessagesOmitTheTask", "When disabled, merge commit messages omit the task ID from the scope (e.g. ")}<code>feat: ...</code>{t("settings.merge.insteadOf", " instead of ")}<code>feat(KB-001): ...</code>)</small>
</details>
</div>
<div className="form-group">
<label htmlFor="commitAuthorEnabled" className="checkbox-label">
<input
id="commitAuthorEnabled"
type="checkbox"
checked={form.commitAuthorEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))
}
/>
Add Fusion as co-author on commits
</label>
<input id="commitAuthorEnabled" type="checkbox" checked={form.commitAuthorEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))}/>{t("settings.merge.addFusionAsCoAuthorOnCommits", " Add Fusion as co-author on commits ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
When enabled, commits made by Fusion keep your git identity as the
primary author and append a <code>Co-authored-by</code> trailer crediting
Fusion (recognized by GitHub for shared attribution).
</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.whenEnabledCommitsMadeByFusionKeepYour", " When enabled, commits made by Fusion keep your git identity as the primary author and append a ")}<code>Co-authored-by</code>{t("settings.merge.trailerCreditingFusionRecognizedByGitHubForShared", " trailer crediting Fusion (recognized by GitHub for shared attribution). ")}</small>
</details>
</div>
{form.commitAuthorEnabled !== false && (
<>
{form.commitAuthorEnabled !== false && (<>
<div className="form-group">
<label htmlFor="commitAuthorName">Co-author Name</label>
<input
id="commitAuthorName"
type="text"
value={form.commitAuthorName ?? ""}
placeholder="Fusion"
onChange={(e) =>
setForm((f) => ({
...f,
commitAuthorName: e.target.value || undefined,
}))
}
/>
<small>Name used in the <code>Co-authored-by</code> trailer</small>
<label htmlFor="commitAuthorName">{t("settings.merge.coAuthorName", "Co-author Name")}</label>
<input id="commitAuthorName" type="text" value={form.commitAuthorName ?? ""} placeholder={t("settings.merge.fusion", "Fusion")} onChange={(e) => setForm((f) => ({
...f,
commitAuthorName: e.target.value || undefined,
}))}/>
<small>{t("settings.merge.nameUsedInThe", "Name used in the ")}<code>Co-authored-by</code>{t("settings.merge.trailer", " trailer")}</small>
</div>
<div className="form-group">
<label htmlFor="commitAuthorEmail">Co-author Email</label>
<input
id="commitAuthorEmail"
type="email"
value={form.commitAuthorEmail ?? ""}
placeholder="noreply@runfusion.ai"
onChange={(e) =>
setForm((f) => ({
...f,
commitAuthorEmail: e.target.value || undefined,
}))
}
/>
<small>Email used in the <code>Co-authored-by</code> trailer</small>
<label htmlFor="commitAuthorEmail">{t("settings.merge.coAuthorEmail", "Co-author Email")}</label>
<input id="commitAuthorEmail" type="email" value={form.commitAuthorEmail ?? ""} placeholder={t("settings.merge.noreplyRunfusionAi", "noreply@runfusion.ai")} onChange={(e) => setForm((f) => ({
...f,
commitAuthorEmail: e.target.value || undefined,
}))}/>
<small>{t("settings.merge.emailUsedInThe", "Email used in the ")}<code>Co-authored-by</code>{t("settings.merge.trailer", " trailer")}</small>
</div>
</>
)}
</>)}
<div className="form-group">
<label htmlFor="autoResolveConflicts" className="checkbox-label">
<input
id="autoResolveConflicts"
type="checkbox"
checked={form.autoResolveConflicts !== false}
onChange={(e) =>
setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))
}
/>
Auto-resolve conflicts in lock files and generated files
</label>
<input id="autoResolveConflicts" type="checkbox" checked={form.autoResolveConflicts !== false} onChange={(e) => setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))}/>{t("settings.merge.autoResolveConflictsInLockFilesAndGenerated", " Auto-resolve conflicts in lock files and generated files ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>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.</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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.")}</small>
</details>
</div>
{(form.merger?.mode ?? "ai") !== "ai" && (
<>
{(form.merger?.mode ?? "ai") !== "ai" && (<>
<div className="form-group">
<label htmlFor="smartConflictResolution" className="checkbox-label">
<input
id="smartConflictResolution"
type="checkbox"
checked={form.smartConflictResolution !== false}
onChange={(e) =>
setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))
}
/>
Smart conflict resolution
</label>
<input id="smartConflictResolution" type="checkbox" checked={form.smartConflictResolution !== false} onChange={(e) => setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))}/>{t("settings.merge.smartConflictResolution", " Smart conflict resolution ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>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.</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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.")}</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeConflictStrategy">Conflict Fallback Strategy</label>
<select
id="mergeConflictStrategy"
value={form.mergeConflictStrategy ?? "smart-prefer-main"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))
}
>
<option value="smart-prefer-main">Smart, prefer main on fallback — fetch+ff origin → AI → auto-resolve → -X ours (default; protects just-merged sibling work)</option>
<option value="smart-prefer-branch">Smart, prefer task on fallback — fetch+ff origin → AI → auto-resolve → -X theirs (legacy "smart" behavior; task branch wins)</option>
<option value="ai-only">AI only — AI → auto-resolve → AI retry; never silently pick a side</option>
<option value="abort">Abort — one AI attempt; require manual resolution if it fails</option>
<label htmlFor="mergeConflictStrategy">{t("settings.merge.conflictFallbackStrategy", "Conflict Fallback Strategy")}</label>
<select id="mergeConflictStrategy" value={form.mergeConflictStrategy ?? "smart-prefer-main"} onChange={(e) => setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))}>
<option value="smart-prefer-main">{t("settings.merge.smartPreferMainOnFallbackFetchFfOrigin", "Smart, prefer main on fallback \u2014 fetch+ff origin \u2192 AI \u2192 auto-resolve \u2192 -X ours (default; protects just-merged sibling work)")}</option>
<option value="smart-prefer-branch">{t("settings.merge.smartPreferTaskOnFallbackFetchFfOrigin", "Smart, prefer task on fallback \u2014 fetch+ff origin \u2192 AI \u2192 auto-resolve \u2192 -X theirs (legacy \"smart\" behavior; task branch wins)")}</option>
<option value="ai-only">{t("settings.merge.aIOnlyAIAutoResolveAIRetryNever", "AI only \u2014 AI \u2192 auto-resolve \u2192 AI retry; never silently pick a side")}</option>
<option value="abort">{t("settings.merge.abortOneAIAttemptRequireManualResolutionIf", "Abort \u2014 one AI attempt; require manual resolution if it fails")}</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Both <strong>Smart</strong> options start with a best-effort <code>git fetch</code> + fast-forward of local main from <code>origin</code> (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 <em>final fallback</em>:
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.both", " Both ")}<strong>{t("settings.merge.smart", "Smart")}</strong>{t("settings.merge.optionsStartWithABestEffort", " options start with a best-effort ")}<code>git fetch</code>{t("settings.merge.fastForwardOfLocalMainFrom", " + fast-forward of local main from ")}<code>origin</code>{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 ")}<em>{t("settings.merge.finalFallback", "final fallback")}</em>:
{" "}
<strong>Smart, prefer main</strong> uses <code>-X ours</code> so main wins — protects just-merged sibling work and is the new default.
{" "}
<strong>Smart, prefer task</strong> uses <code>-X theirs</code> so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression).
{" "}
<strong>AI only</strong> retries the AI agent rather than auto-picking a side.
{" "}
<strong>Abort</strong> stops after the first AI attempt and waits for a human.
{" "}
<em>Legacy <code>"smart"</code> and <code>"prefer-main"</code> values from older settings are migrated automatically.</em>
<strong>{t("settings.merge.smartPreferMain", "Smart, prefer main")}</strong>{t("settings.merge.uses", " uses ")}<code>-X ours</code>{t("settings.merge.soMainWinsProtectsJustMergedSiblingWork", " so main wins \u2014 protects just-merged sibling work and is the new default. ")}{" "}
<strong>{t("settings.merge.smartPreferTask", "Smart, prefer task")}</strong>{t("settings.merge.uses", " uses ")}<code>-X theirs</code>{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). ")}{" "}
<strong>{t("settings.merge.aIOnly", "AI only")}</strong>{t("settings.merge.retriesTheAIAgentRatherThanAutoPicking", " retries the AI agent rather than auto-picking a side. ")}{" "}
<strong>{t("settings.merge.abort", "Abort")}</strong>{t("settings.merge.stopsAfterTheFirstAIAttemptAndWaits", " stops after the first AI attempt and waits for a human. ")}{" "}
<em>{t("settings.merge.legacy2", "Legacy ")}<code>"smart"</code>{t("settings.merge.and2", " and ")}<code>"prefer-main"</code>{t("settings.merge.valuesFromOlderSettingsAreMigratedAutomatically", " values from older settings are migrated automatically.")}</em>
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeStrategyOverlapBehavior">Smart Prefer Main Overlap Guard</label>
<select
id="mergeStrategyOverlapBehavior"
value={form.mergeStrategyOverlapBehavior ?? "flip-to-prefer-branch"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeStrategyOverlapBehavior: e.target.value as "flip-to-prefer-branch" | "warn-only" | "ignore",
}))
}
>
<option value="flip-to-prefer-branch">Flip overlapping files to prefer the task branch (default)</option>
<option value="warn-only">Warn only — keep legacy main-wins fallback</option>
<option value="ignore">Ignore overlap detection — preserve legacy behavior</option>
<label htmlFor="mergeStrategyOverlapBehavior">{t("settings.merge.smartPreferMainOverlapGuard", "Smart Prefer Main Overlap Guard")}</label>
<select id="mergeStrategyOverlapBehavior" value={form.mergeStrategyOverlapBehavior ?? "flip-to-prefer-branch"} onChange={(e) => setForm((f) => ({
...f,
mergeStrategyOverlapBehavior: e.target.value as "flip-to-prefer-branch" | "warn-only" | "ignore",
}))}>
<option value="flip-to-prefer-branch">{t("settings.merge.flipOverlappingFilesToPreferTheTaskBranch", "Flip overlapping files to prefer the task branch (default)")}</option>
<option value="warn-only">{t("settings.merge.warnOnlyKeepLegacyMainWinsFallback", "Warn only \u2014 keep legacy main-wins fallback")}</option>
<option value="ignore">{t("settings.merge.ignoreOverlapDetectionPreserveLegacyBehavior", "Ignore overlap detection \u2014 preserve legacy behavior")}</option>
</select>
<small>
When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work.
</small>
<small>{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. ")}</small>
</div>
<div className="form-group">
<label htmlFor="postMergeAuditMode">Post-merge audit mode</label>
<select
className="select"
id="postMergeAuditMode"
value={form.postMergeAuditMode ?? "warn"}
onChange={(e) =>
setForm((f) => ({
...f,
postMergeAuditMode: e.target.value as "block" | "warn" | "off",
}))
}
>
<option value="block">Block (strict)</option>
<option value="warn">Warn (default; log findings, continue)</option>
<option value="off">Off (skip audit)</option>
<label htmlFor="postMergeAuditMode">{t("settings.merge.postMergeAuditMode", "Post-merge audit mode")}</label>
<select className="select" id="postMergeAuditMode" value={form.postMergeAuditMode ?? "warn"} onChange={(e) => setForm((f) => ({
...f,
postMergeAuditMode: e.target.value as "block" | "warn" | "off",
}))}>
<option value="block">{t("settings.merge.blockStrict", "Block (strict)")}</option>
<option value="warn">{t("settings.merge.warnDefaultLogFindingsContinue", "Warn (default; log findings, continue)")}</option>
<option value="off">{t("settings.merge.offSkipAudit", "Off (skip audit)")}</option>
</select>
<small>
Controls the post-merge audit gate. <strong>Warn</strong> (default) logs findings but auto-completes the merge. <strong>Block</strong> is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. <strong>Off</strong> skips the audit entirely. Switching to Off is recommended only if you trust your branches don&apos;t silently drop edits.
</small>
<small>{t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}<strong>{t("settings.merge.warn", "Warn")}</strong>{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}<strong>{t("settings.merge.block", "Block")}</strong>{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}<strong>{t("settings.merge.off", "Off")}</strong>{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don&apos;t silently drop edits. ")}</small>
</div>
</>
)}
</>)}
<div className="form-group">
<label htmlFor="pushAfterMerge" className="checkbox-label">
<input
id="pushAfterMerge"
type="checkbox"
checked={form.pushAfterMerge === true}
onChange={(e) =>
setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))
}
/>
Push to remote after merge
</label>
<input id="pushAfterMerge" type="checkbox" checked={form.pushAfterMerge === true} onChange={(e) => setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))}/>{t("settings.merge.pushToRemoteAfterMerge", " Push to remote after merge ")}</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>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.</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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.")}</small>
</details>
</div>
{form.pushAfterMerge && (
<div className="form-group">
<label htmlFor="pushRemote">Push Remote</label>
<input
id="pushRemote"
type="text"
placeholder="origin"
value={form.pushRemote || ""}
onChange={(e) =>
setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))
}
/>
{form.pushAfterMerge && (<div className="form-group">
<label htmlFor="pushRemote">{t("settings.merge.pushRemote", "Push Remote")}</label>
<input id="pushRemote" type="text" placeholder={t("settings.merge.origin", "origin")} value={form.pushRemote || ""} onChange={(e) => setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))}/>
<details className="settings-option-details">
<summary>More details</summary>
<small>Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin".</small>
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.gitRemoteToPushToEGOrigin", "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".")}</small>
</details>
</div>
)}
</>
);
</div>)}
</>);
}
export default MergeSection;

View File

@@ -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<typeof useTranslation<"app">>["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}
<h4 className="settings-section-heading">Node Routing</h4>
<p className="settings-section-description">Configure how tasks are routed to execution nodes.</p>
<p className="settings-node-routing-note">These settings apply at the project level.</p>
<h4 className="settings-section-heading">{t("settings.nodeRouting.nodeRouting", "Node Routing")}</h4>
<p className="settings-section-description">{t("settings.nodeRouting.configureHowTasksAreRoutedToExecutionNodes", "Configure how tasks are routed to execution nodes.")}</p>
<p className="settings-node-routing-note">{t("settings.nodeRouting.theseSettingsApplyAtTheProjectLevel", "These settings apply at the project level.")}</p>
<div className="form-group">
<label htmlFor="defaultNodeId">Default Execution Node</label>
<select
id="defaultNodeId"
className="select"
value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""}
onChange={(e) => {
<label htmlFor="defaultNodeId">{t("settings.nodeRouting.defaultExecutionNode", "Default Execution Node")}</label>
<select id="defaultNodeId" className="select" value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState));
}}
>
<option value="">Local execution (no default node)</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({getNodeStatusLabel(node.status)})
</option>
))}
}}>
<option value="">{t("settings.nodeRouting.localExecutionNoDefaultNode", "Local execution (no default node)")}</option>
{nodes.map((node) => (<option key={node.id} value={node.id}>
{node.name} ({getNodeStatusLabel(node.status, t)})
</option>))}
</select>
{(() => {
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
if (!selectedNode) return null;
return (
<div className="settings-node-status">
<span>Selected node:</span>
<NodeHealthDot status={selectedNode.status} showLabel />
</div>
);
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
if (!selectedNode)
return null;
return (<div className="settings-node-status">
<span>{t("settings.nodeRouting.selectedNode", "Selected node:")}</span>
<NodeHealthDot status={selectedNode.status} showLabel/>
</div>);
})()}
<small>Used when a task has no node override. Node status is shown for safer routing selection.</small>
<small>{t("settings.nodeRouting.usedWhenATaskHasNoNodeOverride", "Used when a task has no node override. Node status is shown for safer routing selection.")}</small>
</div>
<div className="form-group">
<label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label>
<select
id="unavailableNodePolicy"
className="select"
value={
form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"
}
onChange={(e) =>
setForm((f) => ({
...f,
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
} as SettingsFormState))
}
>
<option value="block">Block execution</option>
<option value="fallback-local">Fall back to local</option>
<label htmlFor="unavailableNodePolicy">{t("settings.nodeRouting.unavailableNodePolicy", "Unavailable Node Policy")}</label>
<select id="unavailableNodePolicy" className="select" value={form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"} onChange={(e) => setForm((f) => ({
...f,
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
} as SettingsFormState))}>
<option value="block">{t("settings.nodeRouting.blockExecution", "Block execution")}</option>
<option value="fallback-local">{t("settings.nodeRouting.fallBackToLocal", "Fall back to local")}</option>
</select>
</div>
</>
);
</>);
}
export default NodeRoutingSection;

View File

@@ -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}
<h4 className="settings-section-heading">Node Sync</h4>
<h4 className="settings-section-heading">{t("settings.nodeSync.nodeSync", "Node Sync")}</h4>
<div className="form-group">
<label htmlFor="settingsSyncEnabled" className="checkbox-label">
<input
id="settingsSyncEnabled"
type="checkbox"
checked={form.settingsSyncEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))}
/>
Enable automatic settings sync
</label>
<small>Automatically synchronize settings between this node and connected remote nodes</small>
<input id="settingsSyncEnabled" type="checkbox" checked={form.settingsSyncEnabled || false} onChange={(e) => setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))}/>{t("settings.nodeSync.enableAutomaticSettingsSync", " Enable automatic settings sync ")}</label>
<small>{t("settings.nodeSync.automaticallySynchronizeSettingsBetweenThisNodeAndConnected", "Automatically synchronize settings between this node and connected remote nodes")}</small>
</div>
{form.settingsSyncEnabled && (
<>
{form.settingsSyncEnabled && (<>
<div className="form-group">
<label htmlFor="settingsSyncAuth" className="checkbox-label">
<input
id="settingsSyncAuth"
type="checkbox"
checked={form.settingsSyncAuth || false}
onChange={(e) => setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))}
/>
Sync model auth credentials
</label>
<small>Include API keys and OAuth tokens in sync operations</small>
<input id="settingsSyncAuth" type="checkbox" checked={form.settingsSyncAuth || false} onChange={(e) => setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))}/>{t("settings.nodeSync.syncModelAuthCredentials", " Sync model auth credentials ")}</label>
<small>{t("settings.nodeSync.includeAPIKeysAndOAuthTokensInSync", "Include API keys and OAuth tokens in sync operations")}</small>
</div>
<div className="form-group">
<label htmlFor="settingsSyncInterval">Sync interval</label>
<select
id="settingsSyncInterval"
className="select"
value={form.settingsSyncInterval || 900000}
onChange={(e) =>
setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))
}
>
<option value={300000}>Every 5 minutes</option>
<option value={900000}>Every 15 minutes</option>
<option value={1800000}>Every 30 minutes</option>
<option value={3600000}>Every 1 hour</option>
<label htmlFor="settingsSyncInterval">{t("settings.nodeSync.syncInterval", "Sync interval")}</label>
<select id="settingsSyncInterval" className="select" value={form.settingsSyncInterval || 900000} onChange={(e) => setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))}>
<option value={300000}>{t("settings.nodeSync.every5Minutes", "Every 5 minutes")}</option>
<option value={900000}>{t("settings.nodeSync.every15Minutes", "Every 15 minutes")}</option>
<option value={1800000}>{t("settings.nodeSync.every30Minutes", "Every 30 minutes")}</option>
<option value={3600000}>{t("settings.nodeSync.every1Hour", "Every 1 hour")}</option>
</select>
</div>
<div className="form-group">
<label htmlFor="settingsSyncConflictResolution">Conflict resolution</label>
<select
id="settingsSyncConflictResolution"
className="select"
value={form.settingsSyncConflictResolution || "last-write-wins"}
onChange={(e) =>
setForm((f) => ({
...f,
settingsSyncConflictResolution: e.target.value as
| "last-write-wins"
| "always-ask"
| "keep-local"
| "keep-remote",
}))
}
>
<option value="last-write-wins">Last write wins</option>
<option value="always-ask">Always ask</option>
<option value="keep-local">Keep local</option>
<option value="keep-remote">Keep remote</option>
<label htmlFor="settingsSyncConflictResolution">{t("settings.nodeSync.conflictResolution", "Conflict resolution")}</label>
<select id="settingsSyncConflictResolution" className="select" value={form.settingsSyncConflictResolution || "last-write-wins"} onChange={(e) => setForm((f) => ({
...f,
settingsSyncConflictResolution: e.target.value as "last-write-wins" | "always-ask" | "keep-local" | "keep-remote",
}))}>
<option value="last-write-wins">{t("settings.nodeSync.lastWriteWins", "Last write wins")}</option>
<option value="always-ask">{t("settings.nodeSync.alwaysAsk", "Always ask")}</option>
<option value="keep-local">{t("settings.nodeSync.keepLocal", "Keep local")}</option>
<option value="keep-remote">{t("settings.nodeSync.keepRemote", "Keep remote")}</option>
</select>
</div>
</>
)}
</>)}
{/* 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. */}
<p className="settings-sync-workflow-note text-muted" role="note">
{t(
"settings.nodeSync.workflowSettingsNotSynced",
"Workflow settings are not synced across nodes yet.",
)}
{t("settings.nodeSync.workflowSettingsNotSynced", "Workflow settings are not synced across nodes yet.")}
</p>
</>
);
</>);
}
export default NodeSyncSection;

View File

@@ -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<string, boolean>;
testNotificationResult: Record<string, { status: "success" | "error"; message: string }>;
onTestProviderNotification: (provider: TestNotificationProvider) => void;
scopeBanner: ReactNode;
testNotificationLoading: Record<string, boolean>;
testNotificationResult: Record<string, {
status: "success" | "error";
message: string;
}>;
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}
<h4 className="settings-section-heading">Notifications</h4>
<h4 className="settings-section-heading">{t("settings.notifications.notifications", "Notifications")}</h4>
<div className="notification-provider-card">
<div className="form-group">
<label htmlFor="failureNotificationMode">Failure notification mode</label>
<select
id="failureNotificationMode"
value={form.failureNotificationMode ?? "sticky-only"}
onChange={(e) => {
const value = e.target.value as "sticky-only" | "all" | "terminal-only";
setForm((f) => ({ ...f, failureNotificationMode: value }));
}}
>
<option value="sticky-only">Sticky failures only (default)</option>
<option value="terminal-only">Terminal failures only (suppress auto-retried)</option>
<option value="all">All failures (legacy)</option>
<label htmlFor="failureNotificationMode">{t("settings.notifications.failureNotificationMode", "Failure notification mode")}</label>
<select id="failureNotificationMode" value={form.failureNotificationMode ?? "sticky-only"} onChange={(e) => {
const value = e.target.value as "sticky-only" | "all" | "terminal-only";
setForm((f) => ({ ...f, failureNotificationMode: value }));
}}>
<option value="sticky-only">{t("settings.notifications.stickyFailuresOnlyDefault", "Sticky failures only (default)")}</option>
<option value="terminal-only">{t("settings.notifications.terminalFailuresOnlySuppressAutoRetried", "Terminal failures only (suppress auto-retried)")}</option>
<option value="all">{t("settings.notifications.allFailuresLegacy", "All failures (legacy)")}</option>
</select>
<small>Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.</small>
<small>{t("settings.notifications.stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits", "Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.")}</small>
</div>
<div className="form-group">
<label htmlFor="failureNotificationDelayMs">Failure notification delay (ms)</label>
<input
id="failureNotificationDelayMs"
type="number"
min={0}
step={1000}
disabled={(form.failureNotificationMode ?? "sticky-only") === "all"}
value={form.failureNotificationDelayMs ?? 30000}
onChange={(e) => {
const parsed = Number(e.target.value);
setForm((f) => ({
<label htmlFor="failureNotificationDelayMs">{t("settings.notifications.failureNotificationDelayMs", "Failure notification delay (ms)")}</label>
<input id="failureNotificationDelayMs" type="number" min={0} step={1000} disabled={(form.failureNotificationMode ?? "sticky-only") === "all"} value={form.failureNotificationDelayMs ?? 30000} onChange={(e) => {
const parsed = Number(e.target.value);
setForm((f) => ({
...f,
failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0,
}));
}}
/>
<small>
How long a failure must persist before a push notification is sent. 0 = notify immediately.
</small>
}));
}}/>
<small>{t("settings.notifications.howLongAFailureMustPersistBeforeA", " How long a failure must persist before a push notification is sent. 0 = notify immediately. ")}</small>
</div>
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>ntfy</strong>
<strong>{t("settings.notifications.ntfy", "ntfy")}</strong>
<label htmlFor="ntfyEnabled" className="checkbox-label">
<input
id="ntfyEnabled"
type="checkbox"
checked={form.ntfyEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))}
/>
Enable
</label>
<input id="ntfyEnabled" type="checkbox" checked={form.ntfyEnabled || false} onChange={(e) => setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))}/>{t("settings.notifications.enable", " Enable ")}</label>
</div>
{form.ntfyEnabled && (
<div className="notification-provider-body">
{form.ntfyEnabled && (<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="ntfyTopic">ntfy Topic</label>
<input
id="ntfyTopic"
type="text"
placeholder="my-topic-name"
value={form.ntfyTopic || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
}}
/>
<small>
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
<a
href="https://ntfy.sh"
target="_blank"
rel="noopener noreferrer"
className="settings-inline-link"
>
Learn more about ntfy.sh
</a>
<label htmlFor="ntfyTopic">{t("settings.notifications.ntfyTopic", "ntfy Topic")}</label>
<input id="ntfyTopic" type="text" placeholder={t("settings.notifications.myTopicName", "my-topic-name")} value={form.ntfyTopic || ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
}}/>
<small>{t("settings.notifications.yourNtfyShTopicName164Alphanumeric", " Your ntfy.sh topic name (1\u201364 alphanumeric/hyphen/underscore characters).")}{" "}
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="settings-inline-link">{t("settings.notifications.learnMoreAboutNtfySh", " Learn more about ntfy.sh ")}</a>
</small>
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
<small className="field-error">
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
</small>
)}
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (<small className="field-error">{t("settings.notifications.topicMustBe164AlphanumericHyphenOr", " Topic must be 1\u201364 alphanumeric, hyphen, or underscore characters ")}</small>)}
<details className="ntfy-advanced-disclosure">
<summary>Advanced</summary>
<summary>{t("settings.notifications.advanced", "Advanced")}</summary>
<div className="ntfy-advanced-content">
<label htmlFor="ntfyBaseUrl">Custom ntfy server URL (optional)</label>
<input
id="ntfyBaseUrl"
type="url"
placeholder="https://ntfy.sh"
value={form.ntfyBaseUrl || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
}}
/>
<small>
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
</small>
<label htmlFor="ntfyAccessToken">Access token (optional)</label>
<input
id="ntfyAccessToken"
type="password"
autoComplete="off"
placeholder="tk_..."
value={form.ntfyAccessToken || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyAccessToken: value || undefined }));
}}
/>
<small>
Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests.
</small>
<label htmlFor="ntfyBaseUrl">{t("settings.notifications.customNtfyServerURLOptional", "Custom ntfy server URL (optional)")}</label>
<input id="ntfyBaseUrl" type="url" placeholder={t("settings.notifications.httpsNtfySh", "https://ntfy.sh")} value={form.ntfyBaseUrl || ""} onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
}}/>
<small>{t("settings.notifications.leaveBlankToKeepTheDefaultServerHttps", " Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. ")}</small>
<label htmlFor="ntfyAccessToken">{t("settings.notifications.accessTokenOptional", "Access token (optional)")}</label>
<input id="ntfyAccessToken" type="password" autoComplete="off" placeholder={t("settings.notifications.tk", "tk_...")} value={form.ntfyAccessToken || ""} onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyAccessToken: value || undefined }));
}}/>
<small>{t("settings.notifications.leaveBlankToPublishWithoutAuthenticationWhenSet", " Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. ")}</small>
</div>
</details>
</div>
<div className="form-group">
<label>Notify on events</label>
<label>{t("settings.notifications.notifyOnEvents", "Notify on events")}</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const checked = form.ntfyEvents?.includes(event) ?? true;
return (
<div key={`ntfy-${event}`}>
const checked = form.ntfyEvents?.includes(event) ?? true;
return (<div key={`ntfy-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== event);
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
<input type="checkbox" checked={checked} onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== event);
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>);
})}
</div>
</div>
<div className="form-group">
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
<input
id="ntfyDashboardHost"
type="text"
placeholder="http://localhost:3000"
value={form.ntfyDashboardHost || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}
/>
<small>
Base URL for deep links in notifications. When set, clicking a notification
opens the dashboard directly to the task.
</small>
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (
<small className="field-error">
Must be a valid URL starting with http:// or https://
</small>
)}
<label htmlFor="ntfyDashboardHost">{t("settings.notifications.dashboardHostname", "Dashboard Hostname")}</label>
<input id="ntfyDashboardHost" type="text" placeholder={t("settings.notifications.httpLocalhost3000", "http://localhost:3000")} value={form.ntfyDashboardHost || ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}/>
<small>{t("settings.notifications.baseURLForDeepLinksInNotificationsWhen", " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. ")}</small>
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (<small className="field-error">{t("settings.notifications.mustBeAValidURLStartingWithHttp", " Must be a valid URL starting with http:// or https:// ")}</small>)}
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
<button type="button" className="btn btn-sm" onClick={() => onTestProviderNotification("ntfy")} disabled={testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)}>
{testNotificationLoading["ntfy"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testNotification", "Test notification")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy-message")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
<button type="button" className="btn btn-sm" onClick={() => onTestProviderNotification("ntfy-message")} disabled={testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)}>
{testNotificationLoading["ntfy-message"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testMessageInbox", "Test message inbox")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy-room")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
<button type="button" className="btn btn-sm" onClick={() => onTestProviderNotification("ntfy-room")} disabled={testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)}>
{testNotificationLoading["ntfy-room"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testRoomReply", "Test room reply")}
</button>
</div>
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
<div className="notification-test-feedback" aria-live="polite">
{testNotificationResult["ntfy"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>
General: {testNotificationResult["ntfy"].message}
</small>
)}
{testNotificationResult["ntfy-message"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-message"].status}`}>
Message inbox: {testNotificationResult["ntfy-message"].message}
</small>
)}
{testNotificationResult["ntfy-room"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-room"].status}`}>
Room reply: {testNotificationResult["ntfy-room"].message}
</small>
)}
</div>
)}
</div>
)}
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (<div className="notification-test-feedback" aria-live="polite">
{testNotificationResult["ntfy"] && (<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>{t("settings.notifications.general", " General: ")}{testNotificationResult["ntfy"].message}
</small>)}
{testNotificationResult["ntfy-message"] && (<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-message"].status}`}>{t("settings.notifications.messageInbox", " Message inbox: ")}{testNotificationResult["ntfy-message"].message}
</small>)}
{testNotificationResult["ntfy-room"] && (<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-room"].status}`}>{t("settings.notifications.roomReply", " Room reply: ")}{testNotificationResult["ntfy-room"].message}
</small>)}
</div>)}
</div>)}
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>Webhook</strong>
<strong>{t("settings.notifications.webhook", "Webhook")}</strong>
<label htmlFor="webhookEnabled" className="checkbox-label">
<input
id="webhookEnabled"
type="checkbox"
checked={form.webhookEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))}
/>
Webhook notifications
</label>
<input id="webhookEnabled" type="checkbox" checked={form.webhookEnabled || false} onChange={(e) => setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))}/>{t("settings.notifications.webhookNotifications", " Webhook notifications ")}</label>
</div>
{form.webhookEnabled && (
<div className="notification-provider-body">
{form.webhookEnabled && (<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="webhookUrl">Webhook URL</label>
<input
id="webhookUrl"
type="text"
placeholder="https://hooks.example.com/..."
value={form.webhookUrl || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, webhookUrl: val || undefined }));
}}
/>
<label htmlFor="webhookUrl">{t("settings.notifications.webhookURL", "Webhook URL")}</label>
<input id="webhookUrl" type="text" placeholder={t("settings.notifications.httpsHooksExampleCom", "https://hooks.example.com/...")} value={form.webhookUrl || ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, webhookUrl: val || undefined }));
}}/>
</div>
<div className="form-group">
<label htmlFor="webhookFormat">Format</label>
<select
id="webhookFormat"
value={form.webhookFormat || "generic"}
onChange={(e) => {
const val = e.target.value as "slack" | "discord" | "generic";
setForm((f) => ({ ...f, webhookFormat: val }));
}}
>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic</option>
<label htmlFor="webhookFormat">{t("settings.notifications.format", "Format")}</label>
<select id="webhookFormat" value={form.webhookFormat || "generic"} onChange={(e) => {
const val = e.target.value as "slack" | "discord" | "generic";
setForm((f) => ({ ...f, webhookFormat: val }));
}}>
<option value="slack">{t("settings.notifications.slack", "Slack")}</option>
<option value="discord">{t("settings.notifications.discord", "Discord")}</option>
<option value="generic">{t("settings.notifications.generic", "Generic")}</option>
</select>
</div>
<div className="form-group">
<label>Notify on events</label>
<label>{t("settings.notifications.notifyOnEvents", "Notify on events")}</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const checked = currentEvents.includes(event);
return (
<div key={`webhook-${event}`}>
const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const checked = currentEvents.includes(event);
return (<div key={`webhook-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev) => ev !== event);
setForm((f) => ({ ...f, webhookEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
<input type="checkbox" checked={checked} onChange={(e) => {
const current = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev) => ev !== event);
setForm((f) => ({ ...f, webhookEvents: newEvents.length > 0 ? newEvents : undefined }));
}}/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>);
})}
</div>
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("webhook")}
disabled={testNotificationLoading["webhook"] || !form.webhookUrl}
>
<button type="button" className="btn btn-sm" onClick={() => onTestProviderNotification("webhook")} disabled={testNotificationLoading["webhook"] || !form.webhookUrl}>
{testNotificationLoading["webhook"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testNotification", "Test notification")}
</button>
</div>
{testNotificationResult["webhook"] && (
<div className="notification-test-feedback" aria-live="polite">
{testNotificationResult["webhook"] && (<div className="notification-test-feedback" aria-live="polite">
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["webhook"].status}`}>
{testNotificationResult["webhook"].message}
</small>
</div>
)}
</div>
)}
</div>)}
</div>)}
</div>
</>
);
</>);
}
export default NotificationsSection;

View File

@@ -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}
<h4 className="settings-section-heading">Plugins</h4>
<div className="settings-plugins-subsection-toggle" role="tablist" aria-label="Plugin manager type">
<button
type="button"
id="plugins-tab-fusion-plugins"
role="tab"
aria-controls="plugins-panel-fusion-plugins"
aria-selected={activePluginsSubsection === "fusion-plugins"}
tabIndex={activePluginsSubsection === "fusion-plugins" ? 0 : -1}
className={`settings-plugins-subsection-btn${activePluginsSubsection === "fusion-plugins" ? " active" : ""}`}
onClick={() => setActivePluginsSubsection("fusion-plugins")}
>
Fusion Plugins
</button>
<button
type="button"
id="plugins-tab-pi-extensions"
role="tab"
aria-controls="plugins-panel-pi-extensions"
aria-selected={activePluginsSubsection === "pi-extensions"}
tabIndex={activePluginsSubsection === "pi-extensions" ? 0 : -1}
className={`settings-plugins-subsection-btn${activePluginsSubsection === "pi-extensions" ? " active" : ""}`}
onClick={() => setActivePluginsSubsection("pi-extensions")}
>
Pi Extensions
</button>
<h4 className="settings-section-heading">{t("settings.plugins.plugins", "Plugins")}</h4>
<div className="settings-plugins-subsection-toggle" role="tablist" aria-label={t("settings.plugins.pluginManagerType", "Plugin manager type")}>
<button type="button" id="plugins-tab-fusion-plugins" role="tab" aria-controls="plugins-panel-fusion-plugins" aria-selected={activePluginsSubsection === "fusion-plugins"} tabIndex={activePluginsSubsection === "fusion-plugins" ? 0 : -1} className={`settings-plugins-subsection-btn${activePluginsSubsection === "fusion-plugins" ? " active" : ""}`} onClick={() => setActivePluginsSubsection("fusion-plugins")}>{t("settings.plugins.fusionPlugins", " Fusion Plugins ")}</button>
<button type="button" id="plugins-tab-pi-extensions" role="tab" aria-controls="plugins-panel-pi-extensions" aria-selected={activePluginsSubsection === "pi-extensions"} tabIndex={activePluginsSubsection === "pi-extensions" ? 0 : -1} className={`settings-plugins-subsection-btn${activePluginsSubsection === "pi-extensions" ? " active" : ""}`} onClick={() => setActivePluginsSubsection("pi-extensions")}>{t("settings.plugins.piExtensions", " Pi Extensions ")}</button>
</div>
<div
id="plugins-panel-fusion-plugins"
role="tabpanel"
aria-labelledby="plugins-tab-fusion-plugins"
className="settings-plugins-subsection-panel"
hidden={activePluginsSubsection !== "fusion-plugins"}
>
{activePluginsSubsection === "fusion-plugins" && (
<>
<div id="plugins-panel-fusion-plugins" role="tabpanel" aria-labelledby="plugins-tab-fusion-plugins" className="settings-plugins-subsection-panel" hidden={activePluginsSubsection !== "fusion-plugins"}>
{activePluginsSubsection === "fusion-plugins" && (<>
<Suspense fallback={null}>
<PluginManager addToast={addToast} projectId={projectId} />
<PluginManager addToast={addToast} projectId={projectId}/>
</Suspense>
<PluginSlot slotId="settings-section" projectId={projectId} />
</>
)}
<PluginSlot slotId="settings-section" projectId={projectId}/>
</>)}
</div>
<div
id="plugins-panel-pi-extensions"
role="tabpanel"
aria-labelledby="plugins-tab-pi-extensions"
className="settings-plugins-subsection-panel"
hidden={activePluginsSubsection !== "pi-extensions"}
>
{activePluginsSubsection === "pi-extensions" && (
<Suspense fallback={null}>
<PiExtensionsManager addToast={addToast} projectId={projectId} />
</Suspense>
)}
<div id="plugins-panel-pi-extensions" role="tabpanel" aria-labelledby="plugins-tab-pi-extensions" className="settings-plugins-subsection-panel" hidden={activePluginsSubsection !== "pi-extensions"}>
{activePluginsSubsection === "pi-extensions" && (<Suspense fallback={null}>
<PiExtensionsManager addToast={addToast} projectId={projectId}/>
</Suspense>)}
</div>
</>
);
</>);
}
export default PluginsSection;

View File

@@ -1,424 +1,332 @@
/**
* Remote Access section (U9 / KTD-10).
*
* Tunnel status, provider selection (Tailscale / Cloudflare), cloudflared
* install affordance, start/stop/use-existing flows, and the auth-link / QR
* tooling. This section is heavily stateful and side-effecting; rather than
* lift all of that into the section, the shell continues to own the remote
* state machine (status polling, busy-action guard, install handler, token
* previews) and the `runRemoteAction` wrapper. The section receives them via a
* single `remote` prop bag plus the modal form. API calls are imported directly
* here (pure module functions) so they don't bloat the prop surface. Behavior
* and i18n keys are preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Globe, CheckCircle, AlertTriangle } from "lucide-react";
import {
updateRemoteSettings,
startRemoteTunnel,
stopRemoteTunnel,
killExternalTunnel,
regenerateRemotePersistentToken,
generateShortLivedRemoteToken,
fetchRemoteUrl,
fetchRemoteQr,
type RemoteSettings,
type RemoteStatus,
} from "../../../api";
import { updateRemoteSettings, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteUrl, fetchRemoteQr, type RemoteSettings, type RemoteStatus, } from "../../../api";
import type { ToastType } from "../../../hooks/useToast";
import type { SectionBaseProps, SettingsFormState } from "./context";
export interface RemoteSectionData {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
remoteStatus: RemoteStatus | null;
externalTunnel: { provider: string; url: string | null } | null;
tunnelShareLink: { url: string; qrSvg: string | null } | null;
remoteBusyAction: string | null;
cloudflaredInstalling: boolean;
cloudflaredInstallError: string | null;
cloudflaredManualInstallCommand: () => string;
cloudflaredMacFallbackCommand: () => string | null;
handleInstallCloudflared: () => Promise<void>;
runRemoteAction: (label: string, action: () => Promise<void>) => Promise<void>;
remoteShortLivedToken: { token: string; expiresAt: string; ttlMs: number } | null;
setRemoteShortLivedToken: (value: { token: string; expiresAt: string; ttlMs: number } | null) => void;
remoteAuthLinkTokenType: "persistent" | "short-lived";
setRemoteAuthLinkTokenType: (value: "persistent" | "short-lived") => void;
remoteUrlPreview: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null;
setRemoteUrlPreview: (
value: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null,
) => void;
remoteQrSvg: string | null;
setRemoteQrSvg: (value: string | null) => void;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
remoteStatus: RemoteStatus | null;
externalTunnel: {
provider: string;
url: string | null;
} | null;
tunnelShareLink: {
url: string;
qrSvg: string | null;
} | null;
remoteBusyAction: string | null;
cloudflaredInstalling: boolean;
cloudflaredInstallError: string | null;
cloudflaredManualInstallCommand: () => string;
cloudflaredMacFallbackCommand: () => string | null;
handleInstallCloudflared: () => Promise<void>;
runRemoteAction: (label: string, action: () => Promise<void>) => Promise<void>;
remoteShortLivedToken: {
token: string;
expiresAt: string;
ttlMs: number;
} | null;
setRemoteShortLivedToken: (value: {
token: string;
expiresAt: string;
ttlMs: number;
} | null) => void;
remoteAuthLinkTokenType: "persistent" | "short-lived";
setRemoteAuthLinkTokenType: (value: "persistent" | "short-lived") => void;
remoteUrlPreview: {
url: string;
expiresAt: string | null;
tokenType: "persistent" | "short-lived";
} | null;
setRemoteUrlPreview: (value: {
url: string;
expiresAt: string | null;
tokenType: "persistent" | "short-lived";
} | null) => void;
remoteQrSvg: string | null;
setRemoteQrSvg: (value: string | null) => void;
}
export interface RemoteSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
remote: RemoteSectionData;
scopeBanner: ReactNode;
remote: RemoteSectionData;
}
export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSectionProps) {
const { t } = useTranslation("app");
const {
projectId,
addToast,
remoteStatus,
externalTunnel,
tunnelShareLink,
remoteBusyAction,
cloudflaredInstalling,
cloudflaredInstallError,
cloudflaredManualInstallCommand,
cloudflaredMacFallbackCommand,
handleInstallCloudflared,
runRemoteAction,
remoteShortLivedToken,
setRemoteShortLivedToken,
remoteAuthLinkTokenType,
setRemoteAuthLinkTokenType,
remoteUrlPreview,
setRemoteUrlPreview,
remoteQrSvg,
setRemoteQrSvg,
} = remote;
const remoteForm = form as Record<string, unknown>;
const activeProvider = (remoteForm.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
const tunnelState = (remoteStatus?.state as RemoteStatus["state"] | "error" | undefined) ?? "stopped";
const statusColor =
tunnelState === "running"
? "running"
: tunnelState === "starting"
? "starting"
: tunnelState === "failed" || tunnelState === "error"
? "error"
: "stopped";
const buildSavePayload = (provider: "tailscale" | "cloudflare"): Partial<RemoteSettings> => {
const formState = form as Record<string, unknown>;
return {
remoteActiveProvider: provider,
remoteTailscaleEnabled: provider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: provider === "cloudflare",
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
const { t } = useTranslation("app");
const { projectId, addToast, remoteStatus, externalTunnel, tunnelShareLink, remoteBusyAction, cloudflaredInstalling, cloudflaredInstallError, cloudflaredManualInstallCommand, cloudflaredMacFallbackCommand, handleInstallCloudflared, runRemoteAction, remoteShortLivedToken, setRemoteShortLivedToken, remoteAuthLinkTokenType, setRemoteAuthLinkTokenType, remoteUrlPreview, setRemoteUrlPreview, remoteQrSvg, setRemoteQrSvg, } = remote;
const remoteForm = form as Record<string, unknown>;
const activeProvider = (remoteForm.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
const tunnelState = (remoteStatus?.state as RemoteStatus["state"] | "error" | undefined) ?? "stopped";
const statusColor = tunnelState === "running"
? "running"
: tunnelState === "starting"
? "starting"
: tunnelState === "failed" || tunnelState === "error"
? "error"
: "stopped";
const buildSavePayload = (provider: "tailscale" | "cloudflare"): Partial<RemoteSettings> => {
const formState = form as Record<string, unknown>;
return {
remoteActiveProvider: provider,
remoteTailscaleEnabled: provider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: provider === "cloudflare",
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
};
};
};
return (
<>
return (<>
{scopeBanner}
<h4 className="settings-section-heading">Remote Access</h4>
<h4 className="settings-section-heading">{t("settings.remote.remoteAccess", "Remote Access")}</h4>
<div className={`remote-status-bar remote-status-bar--${statusColor}`}>
<span className={`remote-status-dot remote-status-dot--${statusColor}`} />
<span className={`remote-status-dot remote-status-dot--${statusColor}`}/>
<strong>{tunnelState}</strong>
{remoteStatus?.provider && <span> · {remoteStatus.provider}</span>}
{remoteStatus?.url && <code className="remote-status-url">{remoteStatus.url}</code>}
{remoteStatus?.lastError && <span className="field-error">{remoteStatus.lastError}</span>}
</div>
{tunnelState === "stopped" && externalTunnel && (
<div className="remote-external-tunnel-panel" role="status">
{tunnelState === "stopped" && externalTunnel && (<div className="remote-external-tunnel-panel" role="status">
<div className="remote-external-tunnel-header">
<Globe aria-hidden="true" />
<strong>External {externalTunnel.provider} tunnel detected</strong>
<Globe aria-hidden="true"/>
<strong>{t("settings.remote.external", "External ")}{externalTunnel.provider}{t("settings.remote.tunnelDetected", " tunnel detected")}</strong>
</div>
{externalTunnel.url && <code className="settings-url-output">{externalTunnel.url}</code>}
{tunnelShareLink?.qrSvg && (
<div className="remote-external-tunnel-qr">
<small>Scan to open:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="External tunnel QR code"
className="settings-qr-preview-image"
/>
</div>
)}
</div>
)}
{tunnelShareLink?.qrSvg && (<div className="remote-external-tunnel-qr">
<small>{t("settings.remote.scanToOpen", "Scan to open:")}</small>
<img src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`} alt={t("settings.remote.externalTunnelQRCode", "External tunnel QR code")} className="settings-qr-preview-image"/>
</div>)}
</div>)}
{tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => {
let accessCode: string | null = null;
let tailnetUrl: string | null = remoteStatus?.url ?? null;
if (tunnelShareLink?.url) {
try {
const parsed = new URL(tunnelShareLink.url);
accessCode = parsed.searchParams.get("rt");
if (!tailnetUrl) tailnetUrl = `${parsed.origin}/`;
} catch {
// fall through
}
}
return (
<div className="remote-share-block">
{tailnetUrl && (
<div className="remote-share-row">
<small>Tailnet URL:</small>
let accessCode: string | null = null;
let tailnetUrl: string | null = remoteStatus?.url ?? null;
if (tunnelShareLink?.url) {
try {
const parsed = new URL(tunnelShareLink.url);
accessCode = parsed.searchParams.get("rt");
if (!tailnetUrl)
tailnetUrl = `${parsed.origin}/`;
}
catch {
// fall through
}
}
return (<div className="remote-share-block">
{tailnetUrl && (<div className="remote-share-row">
<small>{t("settings.remote.tailnetURL", "Tailnet URL:")}</small>
<code className="settings-url-output">{tailnetUrl}</code>
</div>
)}
{accessCode && (
<div className="remote-share-row">
<small>Remote access code:</small>
</div>)}
{accessCode && (<div className="remote-share-row">
<small>{t("settings.remote.remoteAccessCode", "Remote access code:")}</small>
<code className="settings-url-output">{accessCode}</code>
</div>
)}
{tunnelShareLink?.qrSvg && (
<div className="remote-share-row">
<small>Scan to connect:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="Remote access QR code"
className="settings-qr-preview-image"
/>
</div>
)}
</div>
);
})()}
</div>)}
{tunnelShareLink?.qrSvg && (<div className="remote-share-row">
<small>{t("settings.remote.scanToConnect", "Scan to connect:")}</small>
<img src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`} alt={t("settings.remote.remoteAccessQRCode", "Remote access QR code")} className="settings-qr-preview-image"/>
</div>)}
</div>);
})()}
<div className="form-group">
<div className="remote-provider-selector" role="radiogroup" aria-label="Remote provider">
<div className="remote-provider-selector" role="radiogroup" aria-label={t("settings.remote.remoteProvider", "Remote provider")}>
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="tailscale" checked={activeProvider === "tailscale"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "tailscale" } as SettingsFormState))} />
<input type="radio" name="remoteProvider" value="tailscale" checked={activeProvider === "tailscale"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "tailscale" } as SettingsFormState))}/>
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-tailscale" aria-hidden="true"><Globe size={16} /></span>
<span>Tailscale</span>
<span data-testid="remote-provider-icon-tailscale" aria-hidden="true"><Globe size={16}/></span>
<span>{t("settings.remote.tailscale", "Tailscale")}</span>
</span>
</span>
</label>
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="cloudflare" checked={activeProvider === "cloudflare"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "cloudflare" } as SettingsFormState))} />
<input type="radio" name="remoteProvider" value="cloudflare" checked={activeProvider === "cloudflare"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "cloudflare" } as SettingsFormState))}/>
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-cloudflare" aria-hidden="true" className="remote-provider-option-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="remote-cloudflare-option-icon">
<path d="M7 16.5h10.8a2.9 2.9 0 0 0 .3-5.8 4.9 4.9 0 0 0-9.3-1.6A3.6 3.6 0 0 0 7 16.5m-1.9 0h3.2a2.5 2.5 0 0 0 .2-5 3.4 3.4 0 0 0-3.4 3.4c0 .6 0 1 .2 1.6" fill="var(--provider-cloudflare)" />
<path d="M7 16.5h10.8a2.9 2.9 0 0 0 .3-5.8 4.9 4.9 0 0 0-9.3-1.6A3.6 3.6 0 0 0 7 16.5m-1.9 0h3.2a2.5 2.5 0 0 0 .2-5 3.4 3.4 0 0 0-3.4 3.4c0 .6 0 1 .2 1.6" fill="var(--provider-cloudflare)"/>
</svg>
</span>
<span>Cloudflare</span>
<span>{t("settings.remote.cloudflare", "Cloudflare")}</span>
</span>
</span>
</label>
</div>
{!activeProvider && <small>Select a provider above to configure remote access.</small>}
{!activeProvider && <small>{t("settings.remote.selectAProviderAboveToConfigureRemoteAccess", "Select a provider above to configure remote access.")}</small>}
</div>
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && (
<div className="remote-cli-detection remote-cli-detection--available" role="status">
<CheckCircle aria-hidden="true" />
<span>cloudflared is installed</span>
</div>
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && (<div className="remote-cli-detection remote-cli-detection--available" role="status">
<CheckCircle aria-hidden="true"/>
<span>{t("settings.remote.cloudflaredIsInstalled", "cloudflared is installed")}</span>
</div>)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && (
<div className="remote-cli-detection remote-cli-detection--missing" role="status">
<AlertTriangle aria-hidden="true" />
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && (<div className="remote-cli-detection remote-cli-detection--missing" role="status">
<AlertTriangle aria-hidden="true"/>
<div className="remote-cli-detection-content">
<span>cloudflared is not installed</span>
<button
type="button"
className="btn btn-sm"
disabled={cloudflaredInstalling || remoteBusyAction !== null}
onClick={() => void handleInstallCloudflared()}
>
{cloudflaredInstalling ? "Installing…" : "Install cloudflared"}
<span>{t("settings.remote.cloudflaredIsNotInstalled", "cloudflared is not installed")}</span>
<button type="button" className="btn btn-sm" disabled={cloudflaredInstalling || remoteBusyAction !== null} onClick={() => void handleInstallCloudflared()}>
{cloudflaredInstalling ? t("settings.remote.installing", "Installing…") : t("settings.remote.installCloudflared", "Install cloudflared")}
</button>
{cloudflaredInstallError && <small className="remote-cli-install-error">{cloudflaredInstallError}</small>}
<small className="remote-cli-manual">Manual install: <code>{cloudflaredManualInstallCommand()}</code></small>
<small className="remote-cli-manual">{t("settings.remote.manualInstall", "Manual install: ")}<code>{cloudflaredManualInstallCommand()}</code></small>
{cloudflaredMacFallbackCommand()
? <small className="remote-cli-manual">If Homebrew is unavailable: <code>{cloudflaredMacFallbackCommand()}</code></small>
: null}
? <small className="remote-cli-manual">{t("settings.remote.ifHomebrewIsUnavailable", "If Homebrew is unavailable: ")}<code>{cloudflaredMacFallbackCommand()}</code></small>
: null}
</div>
</div>
)}
</div>)}
{activeProvider && (
<div className="form-group remote-provider-settings">
{activeProvider === "tailscale" ? (
<>
<small>Tailscale Funnel will expose this dashboard on your tailnet's public {`https://<machine>.<tailnet>.ts.net/`} URL — no hostname or port configuration needed.</small>
{activeProvider && (<div className="form-group remote-provider-settings">
{activeProvider === "tailscale" ? (<>
<small>{t("settings.remote.tailscaleFunnelWillExposeThisDashboardOnYour", "Tailscale Funnel will expose this dashboard on your tailnet's public ")}{`https://<machine>.<tailnet>.ts.net/`}{t("settings.remote.uRLNoHostnameOrPortConfigurationNeeded", " URL \u2014 no hostname or port configuration needed.")}</small>
<label htmlFor="remoteTailscaleAcceptRoutes" className="checkbox-label">
<input id="remoteTailscaleAcceptRoutes" type="checkbox" checked={Boolean(remoteForm.remoteTailscaleAcceptRoutes)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleAcceptRoutes: e.target.checked } as SettingsFormState))} />
Accept routes
</label>
</>
) : (
<>
<input id="remoteTailscaleAcceptRoutes" type="checkbox" checked={Boolean(remoteForm.remoteTailscaleAcceptRoutes)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleAcceptRoutes: e.target.checked } as SettingsFormState))}/>{t("settings.remote.acceptRoutes", " Accept routes ")}</label>
</>) : (<>
<small>
{(remoteForm.remoteCloudflareQuickTunnel ?? true)
? "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed."
: "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below."}
? t("settings.remote.usingQuickTunnel", "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed.")
: t("settings.remote.namedTunnelModeEnabled", "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below.")}
</small>
<details
className="remote-cf-advanced-details"
open={!(remoteForm.remoteCloudflareQuickTunnel ?? true)}
onToggle={(event) => {
const detailsOpen = event.currentTarget.open;
setForm((f) => {
const currentQuickTunnel = Boolean((f as Record<string, unknown>).remoteCloudflareQuickTunnel ?? true);
const nextQuickTunnel = !detailsOpen;
if (currentQuickTunnel === nextQuickTunnel) {
return f;
}
return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState;
});
}}
>
<summary>Advanced (Named Tunnel)</summary>
{!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? (
<div className="remote-cf-advanced-fields">
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
<input id="remoteCloudflareTunnelName" type="text" placeholder="Tunnel name" value={String(remoteForm.remoteCloudflareTunnelName ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
<input id="remoteCloudflareTunnelToken" type="password" placeholder="Tunnel token" value={String(remoteForm.remoteCloudflareTunnelToken ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
<input id="remoteCloudflareIngressUrl" type="text" placeholder="https://your-domain.example" value={String(remoteForm.remoteCloudflareIngressUrl ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} />
</div>
) : null}
<details className="remote-cf-advanced-details" open={!(remoteForm.remoteCloudflareQuickTunnel ?? true)} onToggle={(event) => {
const detailsOpen = event.currentTarget.open;
setForm((f) => {
const currentQuickTunnel = Boolean((f as Record<string, unknown>).remoteCloudflareQuickTunnel ?? true);
const nextQuickTunnel = !detailsOpen;
if (currentQuickTunnel === nextQuickTunnel) {
return f;
}
return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState;
});
}}>
<summary>{t("settings.remote.advancedNamedTunnel", "Advanced (Named Tunnel)")}</summary>
{!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? (<div className="remote-cf-advanced-fields">
<label htmlFor="remoteCloudflareTunnelName">{t("settings.remote.tunnelName", "Tunnel name")}</label>
<input id="remoteCloudflareTunnelName" type="text" placeholder={t("settings.remote.tunnelName", "Tunnel name")} value={String(remoteForm.remoteCloudflareTunnelName ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))}/>
<label htmlFor="remoteCloudflareTunnelToken">{t("settings.remote.tunnelToken", "Tunnel token")}</label>
<input id="remoteCloudflareTunnelToken" type="password" placeholder={t("settings.remote.tunnelToken", "Tunnel token")} value={String(remoteForm.remoteCloudflareTunnelToken ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))}/>
<label htmlFor="remoteCloudflareIngressUrl">{t("settings.remote.ingressURL", "Ingress URL")}</label>
<input id="remoteCloudflareIngressUrl" type="text" placeholder={t("settings.remote.httpsYourDomainExample", "https://your-domain.example")} value={String(remoteForm.remoteCloudflareIngressUrl ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))}/>
</div>) : null}
</details>
</>
)}
</div>
)}
</>)}
</div>)}
<div className="form-group remote-tunnel-actions">
{tunnelState === "running" || tunnelState === "starting" ? (
<button type="button" className="btn btn-danger" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("stop", async () => {
await stopRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStopped", "Remote tunnel stopped"), "success");
})}>
{tunnelState === "running" || tunnelState === "starting" ? (<button type="button" className="btn btn-danger" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("stop", async () => {
await stopRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStopped", "Remote tunnel stopped"), "success");
})}>
{remoteBusyAction === "stop" ? t("settings.remote.stopping", "Stopping…") : t("settings.remote.stopTunnel", "Stop Tunnel")}
</button>
) : (
<>
{externalTunnel ? (
<div className="remote-external-tunnel-actions">
</button>) : (<>
{externalTunnel ? (<div className="remote-external-tunnel-actions">
<button type="button" className="btn" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start fresh", async () => {
if (!activeProvider) return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await killExternalTunnel(projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelRestarted", "Remote tunnel restarted"), "success");
if (!activeProvider)
return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await killExternalTunnel(projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelRestarted", "Remote tunnel restarted"), "success");
})}>
{remoteBusyAction === "start fresh" ? t("settings.remote.restarting", "Restarting…") : t("settings.remote.startFresh", "Start Fresh")}
</button>
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("use existing", async () => {
if (!activeProvider) return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
if (!activeProvider)
return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
})}>
{remoteBusyAction === "use existing" ? t("settings.remote.starting", "Starting…") : t("settings.remote.useExisting", "Use Existing")}
</button>
</div>
) : (
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
if (!activeProvider) return;
// Server overrides remoteTailscaleTargetPort with
// req.socket.localPort when starting the tunnel; the value sent
// here is only a fallback if that override doesn't fire.
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
})}>
</div>) : (<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
if (!activeProvider)
return;
// Server overrides remoteTailscaleTargetPort with
// req.socket.localPort when starting the tunnel; the value sent
// here is only a fallback if that override doesn't fire.
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
})}>
{remoteBusyAction === "start" ? t("settings.remote.starting", "Starting…") : t("settings.remote.startTunnel", "Start Tunnel")}
</button>
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (
<small className="field-error">cloudflared must be installed to start the tunnel</small>
) : null}
</>
)}
</button>)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (<small className="field-error">{t("settings.remote.cloudflaredMustBeInstalledToStartTheTunnel", "cloudflared must be installed to start the tunnel")}</small>) : null}
</>)}
</div>
<details className="remote-advanced-details">
<summary>Advanced Settings</summary>
<summary>{t("settings.remote.advancedSettings", "Advanced Settings")}</summary>
<div className="form-group">
<label htmlFor="remoteShortLivedEnabled" className="checkbox-label">
<input id="remoteShortLivedEnabled" type="checkbox" checked={Boolean(remoteForm.remoteShortLivedEnabled)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedEnabled: e.target.checked } as SettingsFormState))} />
Enable short-lived tokens
</label>
<label htmlFor="remoteShortLivedTtlMs">Short-lived TTL (ms)</label>
<input id="remoteShortLivedTtlMs" type="number" min={60000} max={86400000} value={Number(remoteForm.remoteShortLivedTtlMs ?? 900000)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))} />
{remoteShortLivedToken && <small>Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms)</small>}
<input id="remoteShortLivedEnabled" type="checkbox" checked={Boolean(remoteForm.remoteShortLivedEnabled)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedEnabled: e.target.checked } as SettingsFormState))}/>{t("settings.remote.enableShortLivedTokens", " Enable short-lived tokens ")}</label>
<label htmlFor="remoteShortLivedTtlMs">{t("settings.remote.shortLivedTTLMs", "Short-lived TTL (ms)")}</label>
<input id="remoteShortLivedTtlMs" type="number" min={60000} max={86400000} value={Number(remoteForm.remoteShortLivedTtlMs ?? 900000)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))}/>
{remoteShortLivedToken && <small>{t("settings.remote.lastShortLivedTokenExpiresAt", "Last short-lived token expires at ")}{new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}{t("settings.remote.ms", "ms)")}</small>}
</div>
<div className="form-group">
<label htmlFor="remoteRememberLastRunning" className="checkbox-label">
<input id="remoteRememberLastRunning" type="checkbox" checked={Boolean(remoteForm.remoteRememberLastRunning)} onChange={(e) => setForm((f) => ({ ...f, remoteRememberLastRunning: e.target.checked } as SettingsFormState))} />
Remember last running state
</label>
<small>Automatically restore tunnel on startup if it was running when last stopped.</small>
<input id="remoteRememberLastRunning" type="checkbox" checked={Boolean(remoteForm.remoteRememberLastRunning)} onChange={(e) => setForm((f) => ({ ...f, remoteRememberLastRunning: e.target.checked } as SettingsFormState))}/>{t("settings.remote.rememberLastRunningState", " Remember last running state ")}</label>
<small>{t("settings.remote.automaticallyRestoreTunnelOnStartupIfItWas", "Automatically restore tunnel on startup if it was running when last stopped.")}</small>
</div>
<div className="form-group">
<label>Auth Links</label>
<label>{t("settings.remote.authLinks", "Auth Links")}</label>
<div className="settings-button-row">
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("regenerate persistent token", async () => {
await regenerateRemotePersistentToken(projectId);
addToast(t("settings.remote.persistentTokenRegenerated", "Persistent token regenerated"), "success");
})}>Regenerate persistent token</button>
await regenerateRemotePersistentToken(projectId);
addToast(t("settings.remote.persistentTokenRegenerated", "Persistent token regenerated"), "success");
})}>{t("settings.remote.regeneratePersistentToken", "Regenerate persistent token")}</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("generate short-lived token", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const generated = await generateShortLivedRemoteToken(ttlMs, projectId);
setRemoteShortLivedToken(generated);
addToast(t("settings.remote.shortLivedTokenGenerated", "Short-lived token generated"), "success");
})}>Generate short-lived token</button>
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const generated = await generateShortLivedRemoteToken(ttlMs, projectId);
setRemoteShortLivedToken(generated);
addToast(t("settings.remote.shortLivedTokenGenerated", "Short-lived token generated"), "success");
})}>{t("settings.remote.generateShortLivedToken", "Generate short-lived token")}</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("fetch remote url", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const nextUrl = await fetchRemoteUrl({ projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview(nextUrl);
setRemoteQrSvg(null);
})}>Show URL</button>
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const nextUrl = await fetchRemoteUrl({ projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview(nextUrl);
setRemoteQrSvg(null);
})}>{t("settings.remote.showURL", "Show URL")}</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("generate QR", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview({ url: qr.url, expiresAt: qr.expiresAt, tokenType: qr.tokenType });
setRemoteQrSvg(qr.data ?? null);
})}>Generate QR</button>
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview({ url: qr.url, expiresAt: qr.expiresAt, tokenType: qr.tokenType });
setRemoteQrSvg(qr.data ?? null);
})}>{t("settings.remote.generateQR", "Generate QR")}</button>
</div>
<label htmlFor="remoteAuthLinkTokenType">Auth link token type</label>
<label htmlFor="remoteAuthLinkTokenType">{t("settings.remote.authLinkTokenType", "Auth link token type")}</label>
<select id="remoteAuthLinkTokenType" value={remoteAuthLinkTokenType} onChange={(e) => setRemoteAuthLinkTokenType(e.target.value as "persistent" | "short-lived")}>
<option value="persistent">Persistent token</option>
<option value="short-lived">Short-lived token</option>
<option value="persistent">{t("settings.remote.persistentToken", "Persistent token")}</option>
<option value="short-lived">{t("settings.remote.shortLivedToken", "Short-lived token")}</option>
</select>
<small>
URL and QR generation use the selected token type.
{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""}
<small>{t("settings.remote.uRLAndQRGenerationUseTheSelectedToken", " URL and QR generation use the selected token type. ")}{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""}
</small>
{remoteUrlPreview?.url && (
<>
<small>Authenticated URL:<code className="settings-url-output">{remoteUrlPreview.url}</code></small>
<small>
Token type: <strong>{remoteUrlPreview.tokenType}</strong>
{remoteUrlPreview.expiresAt ? ` · Expires at ${new Date(remoteUrlPreview.expiresAt).toLocaleString()}` : " · No expiry"}
{remoteUrlPreview?.url && (<>
<small>{t("settings.remote.authenticatedURL", "Authenticated URL:")}<code className="settings-url-output">{remoteUrlPreview.url}</code></small>
<small>{t("settings.remote.tokenType", " Token type: ")}<strong>{remoteUrlPreview.tokenType}</strong>
{remoteUrlPreview.expiresAt
? t("settings.remote.expiresAt", " · Expires at {{expiresAt}}", { expiresAt: new Date(remoteUrlPreview.expiresAt).toLocaleString() })
: t("settings.remote.noExpiry", " · No expiry")}
</small>
</>
)}
{remoteQrSvg && (
<div className="settings-qr-preview" aria-live="polite">
<p className="settings-qr-preview-label">Scan this QR code on your phone</p>
</>)}
{remoteQrSvg && (<div className="settings-qr-preview" aria-live="polite">
<p className="settings-qr-preview-label">{t("settings.remote.scanThisQRCodeOnYourPhone", "Scan this QR code on your phone")}</p>
<div className="settings-qr-preview-image-wrap">
<img src={`data:image/svg+xml;utf8,${encodeURIComponent(remoteQrSvg)}`} alt="Remote access QR code" className="settings-qr-preview-image" />
<img src={`data:image/svg+xml;utf8,${encodeURIComponent(remoteQrSvg)}`} alt={t("settings.remote.remoteAccessQRCode", "Remote access QR code")} className="settings-qr-preview-image"/>
</div>
<details>
<summary>QR SVG markup</summary>
<summary>{t("settings.remote.qRSVGMarkup", "QR SVG markup")}</summary>
<pre className="settings-raw-output">{remoteQrSvg}</pre>
</details>
</div>
)}
</div>)}
</div>
</details>
</>
);
</>);
}
export default RemoteSection;

View File

@@ -1,131 +1,71 @@
/**
* Research Defaults (global) section (U9 / KTD-10).
*
* Global research web-search provider selection (built-in vs external), the
* external-provider advanced disclosure, default run limits, and enabled-source
* toggles. Credential-presence checks read the shell's fetched `authProviders`;
* the "open Authentication" affordances navigate via the shell's
* `onNavigateToSection` so cross-section deep-links keep working.
*/
import type { ReactNode } from "react";
import type { Settings } from "@fusion/core";
import type { AuthProvider } from "../../../api";
import type { SectionId } from "../../SettingsModal";
import type { SectionBaseProps } from "./context";
import { useTranslation } from "react-i18next";
export interface ResearchGlobalSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
authProviders: AuthProvider[];
onNavigateToSection: (section: SectionId) => void;
scopeBanner: ReactNode;
authProviders: AuthProvider[];
onNavigateToSection: (section: SectionId) => void;
}
export function ResearchGlobalSection({
scopeBanner,
form,
setForm,
authProviders,
onNavigateToSection,
}: ResearchGlobalSectionProps) {
const resolvedProvider =
form.researchGlobalWebSearchProvider ??
form.researchGlobalDefaults?.searchProvider ??
"builtin";
const externalProvider =
resolvedProvider === "searxng" ||
resolvedProvider === "brave" ||
resolvedProvider === "google" ||
resolvedProvider === "tavily";
const selectedCredentialProvider =
resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null;
const hasMissingResearchCredential = selectedCredentialProvider
? authProviders.some(
(provider) => provider.id === selectedCredentialProvider && !provider.authenticated,
)
: false;
const setSearchProvider = (provider: Settings["researchGlobalWebSearchProvider"]) => {
setForm((current) => ({
...current,
researchGlobalWebSearchProvider: provider,
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
searchProvider: provider,
},
}));
};
return (
<>
export function ResearchGlobalSection({ scopeBanner, form, setForm, authProviders, onNavigateToSection, }: ResearchGlobalSectionProps) {
const { t } = useTranslation("app");
const resolvedProvider = form.researchGlobalWebSearchProvider ??
form.researchGlobalDefaults?.searchProvider ??
"builtin";
const externalProvider = resolvedProvider === "searxng" ||
resolvedProvider === "brave" ||
resolvedProvider === "google" ||
resolvedProvider === "tavily";
const selectedCredentialProvider = resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null;
const hasMissingResearchCredential = selectedCredentialProvider
? authProviders.some((provider) => provider.id === selectedCredentialProvider && !provider.authenticated)
: false;
const setSearchProvider = (provider: Settings["researchGlobalWebSearchProvider"]) => {
setForm((current) => ({
...current,
researchGlobalWebSearchProvider: provider,
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
searchProvider: provider,
},
}));
};
return (<>
{scopeBanner}
<h4 className="settings-section-heading">Research Defaults</h4>
<h4 className="settings-section-heading">{t("settings.researchGlobal.researchDefaults", "Research Defaults")}</h4>
<div className="form-group settings-research-provider-group">
<label htmlFor="research-global-provider-builtin" className="checkbox-label">
<input
id="research-global-provider-builtin"
type="radio"
name="research-global-search-provider"
checked={!externalProvider}
onChange={() => setSearchProvider("builtin")}
/>
Built-in (uses agent web tools)
</label>
<small>
Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required.
</small>
<input id="research-global-provider-builtin" type="radio" name="research-global-search-provider" checked={!externalProvider} onChange={() => setSearchProvider("builtin")}/>{t("settings.researchGlobal.builtInUsesAgentWebTools", " Built-in (uses agent web tools) ")}</label>
<small>{t("settings.researchGlobal.searchesAndFetchesUseTheAgentsNativeWebSearch", " Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. ")}</small>
<details className="settings-option-details settings-research-provider-advanced-details">
<summary>Advanced — external search providers</summary>
<summary>{t("settings.researchGlobal.advancedExternalSearchProviders", "Advanced \u2014 external search providers")}</summary>
<div className="settings-research-provider-advanced-body">
<div className="form-group">
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
<select
id="research-global-search-provider-advanced"
className="input"
value={externalProvider ? resolvedProvider : "searxng"}
onChange={(event) =>
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
}
>
<option value="searxng">SearXNG</option>
<option value="brave">Brave</option>
<option value="google">Google Custom Search</option>
<option value="tavily">Tavily</option>
<label htmlFor="research-global-search-provider-advanced">{t("settings.researchGlobal.searchProvider", "Search Provider")}</label>
<select id="research-global-search-provider-advanced" className="input" value={externalProvider ? resolvedProvider : "searxng"} onChange={(event) => setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])}>
<option value="searxng">{t("settings.researchGlobal.searXNG", "SearXNG")}</option>
<option value="brave">{t("settings.researchGlobal.brave", "Brave")}</option>
<option value="google">{t("settings.researchGlobal.googleCustomSearch", "Google Custom Search")}</option>
<option value="tavily">{t("settings.researchGlobal.tavily", "Tavily")}</option>
</select>
</div>
<div className="form-group">
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
<input
id="research-global-searxng-url"
className="input"
value={form.researchGlobalSearxngUrl ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalSearxngUrl: event.target.value || undefined,
}))
}
placeholder="https://searx.example.com"
/>
<label htmlFor="research-global-searxng-url">{t("settings.researchGlobal.searXNGURL", "SearXNG URL")}</label>
<input id="research-global-searxng-url" className="input" value={form.researchGlobalSearxngUrl ?? ""} onChange={(event) => setForm((current) => ({
...current,
researchGlobalSearxngUrl: event.target.value || undefined,
}))} placeholder={t("settings.researchGlobal.httpsSearxExampleCom", "https://searx.example.com")}/>
</div>
<div className="form-group">
<label htmlFor="research-global-google-cx">Google Search CX</label>
<input
id="research-global-google-cx"
className="input"
value={form.researchGlobalGoogleSearchCx ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGoogleSearchCx: event.target.value || undefined,
}))
}
placeholder="custom-search-engine-id"
/>
<label htmlFor="research-global-google-cx">{t("settings.researchGlobal.googleSearchCX", "Google Search CX")}</label>
<input id="research-global-google-cx" className="input" value={form.researchGlobalGoogleSearchCx ?? ""} onChange={(event) => setForm((current) => ({
...current,
researchGlobalGoogleSearchCx: event.target.value || undefined,
}))} placeholder={t("settings.researchGlobal.customSearchEngineId", "custom-search-engine-id")}/>
</div>
<div className="settings-empty-state settings-research-empty-state" role="note">
Configure Brave, Tavily, and Google API keys in Authentication.
<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>
Open Authentication Settings
</button>
<div className="settings-empty-state settings-research-empty-state" role="note">{t("settings.researchGlobal.configureBraveTavilyAndGoogleAPIKeysIn", " Configure Brave, Tavily, and Google API keys in Authentication. ")}<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>{t("settings.researchGlobal.openAuthenticationSettings", " Open Authentication Settings ")}</button>
</div>
</div>
</details>
@@ -133,138 +73,66 @@ export function ResearchGlobalSection({
<div className="form-group">
<div className="settings-research-limits-grid">
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-concurrent">Default Max Concurrent Runs</label>
<input
id="research-global-max-concurrent"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxConcurrentRuns ?? 3}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
<label htmlFor="research-global-max-concurrent">{t("settings.researchGlobal.defaultMaxConcurrentRuns", "Default Max Concurrent Runs")}</label>
<input id="research-global-max-concurrent" className="input" type="number" min={1} value={form.researchGlobalMaxConcurrentRuns ?? 3} onChange={(event) => setForm((current) => ({
...current,
researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
}))}/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-sources">Default Max Sources Per Run</label>
<input
id="research-global-max-sources"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
<label htmlFor="research-global-max-sources">{t("settings.researchGlobal.defaultMaxSourcesPerRun", "Default Max Sources Per Run")}</label>
<input id="research-global-max-sources" className="input" type="number" min={1} value={form.researchGlobalMaxSourcesPerRun ?? 20} onChange={(event) => setForm((current) => ({
...current,
researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
}))}/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-default-timeout">Default Max Duration (ms)</label>
<input
id="research-global-default-timeout"
className="input"
type="number"
min={1000}
value={form.researchGlobalDefaultTimeout ?? 300000}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
<label htmlFor="research-global-default-timeout">{t("settings.researchGlobal.defaultMaxDurationMs", "Default Max Duration (ms)")}</label>
<input id="research-global-default-timeout" className="input" type="number" min={1000} value={form.researchGlobalDefaultTimeout ?? 300000} onChange={(event) => setForm((current) => ({
...current,
researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value),
}))}/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-fetch-timeout">Request Timeout (ms)</label>
<input
id="research-global-fetch-timeout"
className="input"
type="number"
min={1000}
value={form.researchGlobalFetchTimeoutMs ?? 30000}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
<label htmlFor="research-global-fetch-timeout">{t("settings.researchGlobal.requestTimeoutMs", "Request Timeout (ms)")}</label>
<input id="research-global-fetch-timeout" className="input" type="number" min={1000} value={form.researchGlobalFetchTimeoutMs ?? 30000} onChange={(event) => setForm((current) => ({
...current,
researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
}))}/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-synthesis-rounds">Max Synthesis Rounds</label>
<input
id="research-global-max-synthesis-rounds"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxSynthesisRounds ?? 2}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
<label htmlFor="research-global-max-synthesis-rounds">{t("settings.researchGlobal.maxSynthesisRounds", "Max Synthesis Rounds")}</label>
<input id="research-global-max-synthesis-rounds" className="input" type="number" min={1} value={form.researchGlobalMaxSynthesisRounds ?? 2} onChange={(event) => setForm((current) => ({
...current,
researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value),
}))}/>
</div>
</div>
</div>
<div className="form-group">
<label>Enabled Sources</label>
<label>{t("settings.researchGlobal.enabledSources", "Enabled Sources")}</label>
<label htmlFor="research-global-source-webSearch" className="checkbox-label settings-research-source-locked">
<input id="research-global-source-webSearch" type="checkbox" checked disabled readOnly />
Web Search <span className="settings-muted">Always on</span>
<input id="research-global-source-webSearch" type="checkbox" checked disabled readOnly/>{t("settings.researchGlobal.webSearch", " Web Search ")}<span className="settings-muted">{t("settings.researchGlobal.alwaysOn", "Always on")}</span>
</label>
<div className="settings-research-source-grid">
<label htmlFor="research-global-source-github" className="checkbox-label">
<input
id="research-global-source-github"
type="checkbox"
checked={form.researchGlobalGitHubEnabled ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGitHubEnabled: event.target.checked,
}))
}
/>
GitHub
</label>
<input id="research-global-source-github" type="checkbox" checked={form.researchGlobalGitHubEnabled ?? false} onChange={(event) => setForm((current) => ({
...current,
researchGlobalGitHubEnabled: event.target.checked,
}))}/>{t("settings.researchGlobal.gitHub", " GitHub ")}</label>
<label htmlFor="research-global-source-local-docs" className="checkbox-label">
<input
id="research-global-source-local-docs"
type="checkbox"
checked={form.researchGlobalLocalDocsEnabled ?? true}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalLocalDocsEnabled: event.target.checked,
}))
}
/>
Local Docs
</label>
<input id="research-global-source-local-docs" type="checkbox" checked={form.researchGlobalLocalDocsEnabled ?? true} onChange={(event) => setForm((current) => ({
...current,
researchGlobalLocalDocsEnabled: event.target.checked,
}))}/>{t("settings.researchGlobal.localDocs", " Local Docs ")}</label>
</div>
</div>
{hasMissingResearchCredential && (
<div className="settings-empty-state" role="alert">
Missing credentials for the selected research provider.
<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>
Open Authentication
</button>
</div>
)}
</>
);
{hasMissingResearchCredential && (<div className="settings-empty-state" role="alert">{t("settings.researchGlobal.missingCredentialsForTheSelectedResearchProvider", " Missing credentials for the selected research provider. ")}<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>{t("settings.researchGlobal.openAuthentication", " Open Authentication ")}</button>
</div>)}
</>);
}
export default ResearchGlobalSection;

View File

@@ -1,183 +1,111 @@
/**
* Project Research Settings section (U9 / KTD-10).
*
* Per-project research enable toggle, enabled-source grid (web search always
* on), and run-limit fields. The limit-validation error is computed in the shell
* (shared with the save gate) and passed down. Keys, nested researchSettings
* shape, and conditional rendering preserved verbatim from the original inline
* JSX.
*/
import type { ReactNode } from "react";
import type { SectionBaseProps } from "./context";
import { useTranslation } from "react-i18next";
export interface ResearchProjectSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
researchLimitError: string | null;
scopeBanner: ReactNode;
researchLimitError: string | null;
}
export function ResearchProjectSection({ scopeBanner, form, setForm, researchLimitError }: ResearchProjectSectionProps) {
const limits = form.researchSettings?.limits;
const sources = form.researchSettings?.enabledSources;
return (
<>
const { t } = useTranslation("app");
const limits = form.researchSettings?.limits;
const sources = form.researchSettings?.enabledSources;
return (<>
{scopeBanner}
<h4 className="settings-section-heading">Project Research Settings</h4>
<h4 className="settings-section-heading">{t("settings.researchProject.projectResearchSettings", "Project Research Settings")}</h4>
<div className="form-group">
<label htmlFor="research-project-enabled" className="checkbox-label">
<input
id="research-project-enabled"
type="checkbox"
checked={form.researchSettings?.enabled ?? true}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable research in this project
</label>
<input id="research-project-enabled" type="checkbox" checked={form.researchSettings?.enabled ?? true} onChange={(event) => setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabled: event.target.checked,
},
}))}/>{t("settings.researchProject.enableResearchInThisProject", " Enable research in this project ")}</label>
</div>
<div className="form-group">
<label>Enabled Sources</label>
<label
htmlFor="research-project-source-webSearch"
className="checkbox-label settings-research-source-locked"
>
<input id="research-project-source-webSearch" type="checkbox" checked disabled readOnly />
Web Search <span className="settings-muted">Always on</span>
<label>{t("settings.researchProject.enabledSources", "Enabled Sources")}</label>
<label htmlFor="research-project-source-webSearch" className="checkbox-label settings-research-source-locked">
<input id="research-project-source-webSearch" type="checkbox" checked disabled readOnly/>{t("settings.researchProject.webSearch", " Web Search ")}<span className="settings-muted">{t("settings.researchProject.alwaysOn", "Always on")}</span>
</label>
<small className="settings-muted">
Web search is always enabled. Configure the search provider under Research Defaults.
</small>
<small className="settings-muted">{t("settings.researchProject.webSearchIsAlwaysEnabledConfigureTheSearch", " Web search is always enabled. Configure the search provider under Research Defaults. ")}</small>
<div className="settings-research-source-grid">
{[
["pageFetch", "Page Fetch"],
["github", "GitHub"],
["localDocs", "Local Docs"],
["llmSynthesis", "LLM Synthesis"],
].map(([key, label]) => (
<label key={key} htmlFor={`research-project-source-${key}`} className="checkbox-label">
<input
id={`research-project-source-${key}`}
type="checkbox"
checked={sources?.[key as keyof NonNullable<typeof sources>] ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabledSources: {
["pageFetch", t("settings.researchProject.pageFetch", "Page Fetch")],
["github", t("settings.researchProject.github", "GitHub")],
["localDocs", t("settings.researchProject.localDocs", "Local Docs")],
["llmSynthesis", t("settings.researchProject.llmSynthesis", "LLM Synthesis")],
].map(([key, label]) => (<label key={key} htmlFor={`research-project-source-${key}`} className="checkbox-label">
<input id={`research-project-source-${key}`} type="checkbox" checked={sources?.[key as keyof NonNullable<typeof sources>] ?? false} onChange={(event) => setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabledSources: {
...(current.researchSettings?.enabledSources ?? {}),
[key]: event.target.checked,
},
},
}))
}
/>
},
}))}/>
{label}
</label>
))}
</label>))}
</div>
</div>
<div className="form-group">
<div className="settings-research-limits-grid">
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-concurrent">Max Concurrent Runs</label>
<input
id="research-project-max-concurrent"
className="input"
type="number"
min={1}
value={limits?.maxConcurrentRuns ?? 3}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
<label htmlFor="research-project-max-concurrent">{t("settings.researchProject.maxConcurrentRuns", "Max Concurrent Runs")}</label>
<input id="research-project-max-concurrent" className="input" type="number" min={1} value={limits?.maxConcurrentRuns ?? 3} onChange={(event) => setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))}/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-sources">Max Sources Per Run</label>
<input
id="research-project-max-sources"
className="input"
type="number"
min={1}
value={limits?.maxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
<label htmlFor="research-project-max-sources">{t("settings.researchProject.maxSourcesPerRun", "Max Sources Per Run")}</label>
<input id="research-project-max-sources" className="input" type="number" min={1} value={limits?.maxSourcesPerRun ?? 20} onChange={(event) => setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))}/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-duration">Max Duration (ms)</label>
<input
id="research-project-max-duration"
className="input"
type="number"
min={1000}
value={limits?.maxDurationMs ?? 300000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
<label htmlFor="research-project-max-duration">{t("settings.researchProject.maxDurationMs", "Max Duration (ms)")}</label>
<input id="research-project-max-duration" className="input" type="number" min={1000} value={limits?.maxDurationMs ?? 300000} onChange={(event) => setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))}/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-request-timeout">Request Timeout (ms)</label>
<input
id="research-project-request-timeout"
className="input"
type="number"
min={1000}
value={limits?.requestTimeoutMs ?? 30000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
<label htmlFor="research-project-request-timeout">{t("settings.researchProject.requestTimeoutMs", "Request Timeout (ms)")}</label>
<input id="research-project-request-timeout" className="input" type="number" min={1000} value={limits?.requestTimeoutMs ?? 30000} onChange={(event) => setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))}/>
</div>
{researchLimitError && <small className="field-error settings-research-limits-error">{researchLimitError}</small>}
</div>
</div>
</>
);
</>);
}
export default ResearchProjectSection;

View File

@@ -1,35 +1,29 @@
/**
* Runtimes group sections (U9 / KTD-10) — thin wrappers around each plugin
* runtime's self-contained card. These sections carry no modal form state; they
* just title and mount the runtime card relocated from SettingsModal's switch.
*/
import { HermesRuntimeCard } from "../../HermesRuntimeCard";
import { OpenClawRuntimeCard } from "../../OpenClawRuntimeCard";
import { PaperclipRuntimeCard } from "../../PaperclipRuntimeCard";
import { useTranslation } from "react-i18next";
/*
* FNXC:i18n-Localization 2026-06-20-00:00:
* Each exported runtime settings section is mounted independently, so each component owns its own translation hook scope.
*/
export function HermesRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">Hermes Runtime</h4>
const { t } = useTranslation("app");
return (<>
<h4 className="settings-section-heading">{t("settings.runtimesRuntimes.hermesRuntime", "Hermes Runtime")}</h4>
<HermesRuntimeCard />
</>
);
</>);
}
export function OpenClawRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">OpenClaw Runtime</h4>
const { t } = useTranslation("app");
return (<>
<h4 className="settings-section-heading">{t("settings.runtimesRuntimes.openClawRuntime", "OpenClaw Runtime")}</h4>
<OpenClawRuntimeCard />
</>
);
</>);
}
export function PaperclipRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">Paperclip Runtime</h4>
const { t } = useTranslation("app");
return (<>
<h4 className="settings-section-heading">{t("settings.runtimesRuntimes.paperclipRuntime", "Paperclip Runtime")}</h4>
<PaperclipRuntimeCard />
</>
);
</>);
}

View File

@@ -1,152 +1,81 @@
/**
* Scheduled Evals section (U9 / KTD-10).
*
* Per-project scheduled evaluation run configuration (enable, interval,
* evaluator provider/model, follow-up policy, retention). Section visibility is
* gated by the shell (evalsViewEnabled). All keys and conditional disabling are
* preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import type { SectionBaseProps } from "./context";
import { useTranslation } from "react-i18next";
export interface ScheduledEvalsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
scopeBanner: ReactNode;
}
export function ScheduledEvalsSection({ scopeBanner, form, setForm }: ScheduledEvalsSectionProps) {
const evalSettings = form.evalSettings ?? {};
const isScheduledEvalEnabled = evalSettings.enabled ?? false;
return (
<>
const { t } = useTranslation("app");
const evalSettings = form.evalSettings ?? {};
const isScheduledEvalEnabled = evalSettings.enabled ?? false;
return (<>
{scopeBanner}
<h4 className="settings-section-heading">Scheduled Evals</h4>
<h4 className="settings-section-heading">{t("settings.scheduledEvals.scheduledEvals", "Scheduled Evals")}</h4>
<div className="form-group">
<label htmlFor="scheduled-evals-enabled" className="checkbox-label">
<input
id="scheduled-evals-enabled"
type="checkbox"
checked={isScheduledEvalEnabled}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable scheduled eval runs for this project
</label>
<input id="scheduled-evals-enabled" type="checkbox" checked={isScheduledEvalEnabled} onChange={(event) => setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
enabled: event.target.checked,
},
}))}/>{t("settings.scheduledEvals.enableScheduledEvalRunsForThisProject", " Enable scheduled eval runs for this project ")}</label>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-interval">Interval (ms)</label>
<input
id="scheduled-evals-interval"
className="input"
type="number"
min={60000}
max={604800000}
step={1000}
disabled={!isScheduledEvalEnabled}
value={evalSettings.intervalMs ?? 86_400_000}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
<label htmlFor="scheduled-evals-interval">{t("settings.scheduledEvals.intervalMs", "Interval (ms)")}</label>
<input id="scheduled-evals-interval" className="input" type="number" min={60000} max={604800000} step={1000} disabled={!isScheduledEvalEnabled} value={evalSettings.intervalMs ?? 86400000} onChange={(event) => setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
intervalMs: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
},
}))}/>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-provider">Evaluator Provider</label>
<input
id="scheduled-evals-provider"
className="input"
value={evalSettings.evaluatorProvider ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
<label htmlFor="scheduled-evals-provider">{t("settings.scheduledEvals.evaluatorProvider", "Evaluator Provider")}</label>
<input id="scheduled-evals-provider" className="input" value={evalSettings.evaluatorProvider ?? ""} onChange={(event) => setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="openai"
/>
},
}))} placeholder={t("settings.scheduledEvals.openai", "openai")}/>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-model">Evaluator Model</label>
<input
id="scheduled-evals-model"
className="input"
value={evalSettings.evaluatorModelId ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
<label htmlFor="scheduled-evals-model">{t("settings.scheduledEvals.evaluatorModel", "Evaluator Model")}</label>
<input id="scheduled-evals-model" className="input" value={evalSettings.evaluatorModelId ?? ""} onChange={(event) => setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorModelId: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="gpt-5"
/>
<small className="form-text text-muted">
Leave provider and model blank to inherit the project validator lane model settings.
</small>
},
}))} placeholder={t("settings.scheduledEvals.gpt5", "gpt-5")}/>
<small className="form-text text-muted">{t("settings.scheduledEvals.leaveProviderAndModelBlankToInheritThe", " Leave provider and model blank to inherit the project validator lane model settings. ")}</small>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-follow-up-policy">Follow-up Policy</label>
<select
id="scheduled-evals-follow-up-policy"
className="select"
disabled={!isScheduledEvalEnabled}
value={evalSettings.followUpPolicy ?? "suggest-only"}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
<label htmlFor="scheduled-evals-follow-up-policy">{t("settings.scheduledEvals.followUpPolicy", "Follow-up Policy")}</label>
<select id="scheduled-evals-follow-up-policy" className="select" disabled={!isScheduledEvalEnabled} value={evalSettings.followUpPolicy ?? "suggest-only"} onChange={(event) => setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
followUpPolicy: event.target.value as "disabled" | "suggest-only" | "auto-create",
},
}))
}
>
<option value="disabled">Disabled</option>
<option value="suggest-only">Suggest only</option>
<option value="auto-create">Auto-create tasks</option>
},
}))}>
<option value="disabled">{t("settings.scheduledEvals.disabled", "Disabled")}</option>
<option value="suggest-only">{t("settings.scheduledEvals.suggestOnly", "Suggest only")}</option>
<option value="auto-create">{t("settings.scheduledEvals.autoCreateTasks", "Auto-create tasks")}</option>
</select>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-retention-days">Retention (days)</label>
<input
id="scheduled-evals-retention-days"
className="input"
type="number"
min={1}
max={365}
step={1}
disabled={!isScheduledEvalEnabled}
value={evalSettings.retentionDays ?? 30}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
<label htmlFor="scheduled-evals-retention-days">{t("settings.scheduledEvals.retentionDays", "Retention (days)")}</label>
<input id="scheduled-evals-retention-days" className="input" type="number" min={1} max={365} step={1} disabled={!isScheduledEvalEnabled} value={evalSettings.retentionDays ?? 30} onChange={(event) => setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
retentionDays: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
},
}))}/>
</div>
</>
);
</>);
}
export default ScheduledEvalsSection;

View File

@@ -1,369 +1,182 @@
/**
* Scheduling section (U9 / KTD-10).
*
* Project-scoped scheduling/capacity knobs: global + per-project concurrency,
* poll interval, heartbeat discipline, stuck/stale detection, plan staleness,
* auto-archive, overlap serialization with the ignored-paths editor, plus the
* step-execution redirect stub (settings moved to the workflow, U4). The global
* concurrency value is shell state (it persists via a separate API and is
* dirty-tracked) and is relayed through props; the overlap-path editor handlers
* also live in the shell (they share the file-browser hook). The day/archive
* constants are co-located. Keys, unit conversions, and conditional disabling
* are preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { MovedSettingsStub } from "./MovedSettingsStub";
import type { SettingsFormState, SetSettingsForm } from "./context";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2;
export interface SchedulingSectionProps {
scopeBanner: ReactNode;
form: SettingsFormState;
setForm: SetSettingsForm;
globalMaxConcurrent: number | undefined;
onGlobalMaxConcurrentChange: (value: number | undefined) => void;
onOverlapIgnorePathChange: (index: number, value: string) => void;
onOpenOverlapPathPicker: (index: number) => void;
onRemoveOverlapIgnorePath: (index: number) => void;
onAddOverlapIgnorePath: () => void;
onOpenWorkflowSettings?: () => void;
scopeBanner: ReactNode;
form: SettingsFormState;
setForm: SetSettingsForm;
globalMaxConcurrent: number | undefined;
onGlobalMaxConcurrentChange: (value: number | undefined) => void;
onOverlapIgnorePathChange: (index: number, value: string) => void;
onOpenOverlapPathPicker: (index: number) => void;
onRemoveOverlapIgnorePath: (index: number) => void;
onAddOverlapIgnorePath: () => void;
onOpenWorkflowSettings?: () => void;
}
export function SchedulingSection({
scopeBanner,
form,
setForm,
globalMaxConcurrent,
onGlobalMaxConcurrentChange,
onOverlapIgnorePathChange,
onOpenOverlapPathPicker,
onRemoveOverlapIgnorePath,
onAddOverlapIgnorePath,
onOpenWorkflowSettings,
}: SchedulingSectionProps) {
const { t } = useTranslation("app");
return (
<>
export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurrent, onGlobalMaxConcurrentChange, onOverlapIgnorePathChange, onOpenOverlapPathPicker, onRemoveOverlapIgnorePath, onAddOverlapIgnorePath, onOpenWorkflowSettings, }: SchedulingSectionProps) {
const { t } = useTranslation("app");
return (<>
{scopeBanner}
<h4 className="settings-section-heading">Scheduling</h4>
<h4 className="settings-section-heading">{t("settings.scheduling.scheduling", "Scheduling")}</h4>
<div className="form-group">
<label htmlFor="globalMaxConcurrent">Global Max Concurrent</label>
<input
id="globalMaxConcurrent"
type="number"
min={0}
max={10000}
value={globalMaxConcurrent ?? ""}
onChange={(e) => {
<label htmlFor="globalMaxConcurrent">{t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")}</label>
<input id="globalMaxConcurrent" type="number" min={0} max={10000} value={globalMaxConcurrent ?? ""} onChange={(e) => {
const val = e.target.value;
onGlobalMaxConcurrentChange(val === "" ? undefined : Number(val));
}}
/>
<small className="form-text text-muted">Maximum concurrent agents across all projects</small>
}}/>
<small className="form-text text-muted">{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects")}</small>
</div>
<div className="form-group">
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
<input
id="maxConcurrent"
type="number"
min={1}
max={10}
value={form.maxConcurrent ?? ""}
onChange={(e) => {
<label htmlFor="maxConcurrent">{t("settings.scheduling.maxConcurrentTasks", "Max Concurrent Tasks")}</label>
<input id="maxConcurrent" type="number" min={1} max={10} value={form.maxConcurrent ?? ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
}}/>
</div>
<div className="form-group">
<label htmlFor="maxTriageConcurrent">Max Triage Concurrent</label>
<input
id="maxTriageConcurrent"
type="number"
min={1}
max={10}
value={form.maxTriageConcurrent ?? ""}
onChange={(e) => {
<label htmlFor="maxTriageConcurrent">{t("settings.scheduling.maxTriageConcurrent", "Max Triage Concurrent")}</label>
<input id="maxTriageConcurrent" type="number" min={1} max={10} value={form.maxTriageConcurrent ?? ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
<small>Maximum concurrent planning agents</small>
}}/>
<small>{t("settings.scheduling.maximumConcurrentPlanningAgents", "Maximum concurrent planning agents")}</small>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
<input
id="pollIntervalMs"
type="number"
min={5000}
step={1000}
value={form.pollIntervalMs ?? ""}
onChange={(e) => {
<label htmlFor="pollIntervalMs">{t("settings.scheduling.pollIntervalMs", "Poll Interval (ms)")}</label>
<input id="pollIntervalMs" type="number" min={5000} step={1000} value={form.pollIntervalMs ?? ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
}}/>
</div>
<div className="form-group">
<label htmlFor="heartbeatScopeDiscipline">Heartbeat Scope Discipline</label>
<select
id="heartbeatScopeDiscipline"
className="select"
value={form.heartbeatScopeDiscipline ?? "strict"}
onChange={(e) => {
<label htmlFor="heartbeatScopeDiscipline">{t("settings.scheduling.heartbeatScopeDiscipline", "Heartbeat Scope Discipline")}</label>
<select id="heartbeatScopeDiscipline" className="select" value={form.heartbeatScopeDiscipline ?? "strict"} onChange={(e) => {
setForm((f) => ({
...f,
heartbeatScopeDiscipline: e.target.value as "strict" | "lite" | "off",
...f,
heartbeatScopeDiscipline: e.target.value as "strict" | "lite" | "off",
}));
}}
>
<option value="strict">Strict (default)</option>
<option value="lite">Lite</option>
<option value="off">Off</option>
}}>
<option value="strict">{t("settings.scheduling.strictDefault", "Strict (default)")}</option>
<option value="lite">{t("settings.scheduling.lite", "Lite")}</option>
<option value="off">{t("settings.scheduling.off", "Off")}</option>
</select>
<small>Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.</small>
<small>{t("settings.scheduling.strictCoordinationFocusedHigherPerTickTokensLite", "Strict \u2014 coordination-focused; higher per-tick tokens. Lite \u2014 pre-2026-05-11 behavior. Off \u2014 minimal procedure.")}</small>
</div>
<div className="form-group">
<label htmlFor="engineerBacklogAutoClaim" className="checkbox-label">
<input
id="engineerBacklogAutoClaim"
type="checkbox"
checked={form.engineerBacklogAutoClaim === true}
onChange={(e) =>
setForm((f) => ({ ...f, engineerBacklogAutoClaim: e.target.checked }))
}
/>
Let engineer agents auto-claim backlog tasks
</label>
<small>Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.</small>
<input id="engineerBacklogAutoClaim" type="checkbox" checked={form.engineerBacklogAutoClaim === true} onChange={(e) => setForm((f) => ({ ...f, engineerBacklogAutoClaim: e.target.checked }))}/>{t("settings.scheduling.letEngineerAgentsAutoClaimBacklogTasks", " Let engineer agents auto-claim backlog tasks ")}</label>
<small>{t("settings.scheduling.backlogNoTaskAutoClaimIsExecutorOnly", "Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.")}</small>
</div>
<div className="form-group">
<label htmlFor="taskStuckTimeoutMs">Stuck Task Timeout (minutes)</label>
<input
id="taskStuckTimeoutMs"
type="number"
min={1}
step={1}
value={form.taskStuckTimeoutMs ? Math.round(form.taskStuckTimeoutMs / 60000) : ""}
onChange={(e) => {
<label htmlFor="taskStuckTimeoutMs">{t("settings.scheduling.stuckTaskTimeoutMinutes", "Stuck Task Timeout (minutes)")}</label>
<input id="taskStuckTimeoutMs" type="number" min={1} step={1} value={form.taskStuckTimeoutMs ? Math.round(form.taskStuckTimeoutMs / 60000) : ""} onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
}}
/>
<small>Timeout in minutes for detecting stuck tasks. When a task&apos;s agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
}}/>
<small>{t("settings.scheduling.timeoutInMinutesForDetectingStuckTasksWhen", "Timeout in minutes for detecting stuck tasks. When a task&apos;s agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.")}</small>
</div>
<div className="form-group">
<label htmlFor="staleHighFanoutBlockerAgeThresholdMs">Stale High Fan-out Escalation (hours)</label>
<input
id="staleHighFanoutBlockerAgeThresholdMs"
type="number"
min={1}
step={1}
value={form.staleHighFanoutBlockerAgeThresholdMs ? Math.round(form.staleHighFanoutBlockerAgeThresholdMs / 3600000) : ""}
onChange={(e) => {
<label htmlFor="staleHighFanoutBlockerAgeThresholdMs">{t("settings.scheduling.staleHighFanOutEscalationHours", "Stale High Fan-out Escalation (hours)")}</label>
<input id="staleHighFanoutBlockerAgeThresholdMs" type="number" min={1} step={1} value={form.staleHighFanoutBlockerAgeThresholdMs ? Math.round(form.staleHighFanoutBlockerAgeThresholdMs / 3600000) : ""} onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({
...f,
staleHighFanoutBlockerAgeThresholdMs: val && num > 0 ? num * 3600000 : undefined,
...f,
staleHighFanoutBlockerAgeThresholdMs: val && num > 0 ? num * 3600000 : undefined,
}));
}}
/>
<small>Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.</small>
}}/>
<small>{t("settings.scheduling.escalateHighFanOutBlockersOnlyAfterThey", "Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.")}</small>
</div>
<div className="form-group">
<label htmlFor="preserveProgressOnStuckRequeue" className="checkbox-label">
<input
id="preserveProgressOnStuckRequeue"
type="checkbox"
checked={form.preserveProgressOnStuckRequeue !== false}
onChange={(e) =>
setForm((f) => ({ ...f, preserveProgressOnStuckRequeue: e.target.checked }))
}
/>
Preserve step progress on stuck-task requeue
</label>
<small>When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled.</small>
<input id="preserveProgressOnStuckRequeue" type="checkbox" checked={form.preserveProgressOnStuckRequeue !== false} onChange={(e) => setForm((f) => ({ ...f, preserveProgressOnStuckRequeue: e.target.checked }))}/>{t("settings.scheduling.preserveStepProgressOnStuckTaskRequeue", " Preserve step progress on stuck-task requeue ")}</label>
<small>{t("settings.scheduling.whenTheStuckDetectorKillsAndReQueues", "When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled.")}</small>
</div>
<div className="form-group">
<label htmlFor="specStalenessEnabled" className="checkbox-label">
<input
id="specStalenessEnabled"
type="checkbox"
checked={form.specStalenessEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))
}
/>
Enable plan staleness enforcement
</label>
<small>When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning</small>
<input id="specStalenessEnabled" type="checkbox" checked={form.specStalenessEnabled || false} onChange={(e) => setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))}/>{t("settings.scheduling.enablePlanStalenessEnforcement", " Enable plan staleness enforcement ")}</label>
<small>{t("settings.scheduling.whenEnabledTasksWithStalePlansPROMPTMd", "When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning")}</small>
</div>
<div className="form-group">
<label htmlFor="specStalenessMaxAgeMs">Stale Spec Threshold (hours)</label>
<input
id="specStalenessMaxAgeMs"
type="number"
min={0}
step={1}
value={form.specStalenessMaxAgeMs !== undefined ? Math.round(form.specStalenessMaxAgeMs / 3600000) : ""}
onChange={(e) => {
<label htmlFor="specStalenessMaxAgeMs">{t("settings.scheduling.staleSpecThresholdHours", "Stale Spec Threshold (hours)")}</label>
<input id="specStalenessMaxAgeMs" type="number" min={0} step={1} value={form.specStalenessMaxAgeMs !== undefined ? Math.round(form.specStalenessMaxAgeMs / 3600000) : ""} onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined }));
}}
disabled={!form.specStalenessEnabled}
/>
<small>Maximum age in hours before a plan is considered stale. Default: 6 hours.</small>
}} disabled={!form.specStalenessEnabled}/>
<small>{t("settings.scheduling.maximumAgeInHoursBeforeAPlanIs", "Maximum age in hours before a plan is considered stale. Default: 6 hours.")}</small>
</div>
<div className="form-group">
<label htmlFor="autoArchiveDoneTasksEnabled" className="checkbox-label">
<input
id="autoArchiveDoneTasksEnabled"
type="checkbox"
checked={form.autoArchiveDoneTasksEnabled ?? true}
onChange={(e) =>
setForm((f) => ({
...f,
autoArchiveDoneTasksEnabled: e.target.checked,
}))
}
/>
Enable automatic task archiving
</label>
<small>Completed tasks older than the threshold are moved out of the active task database.</small>
<input id="autoArchiveDoneTasksEnabled" type="checkbox" checked={form.autoArchiveDoneTasksEnabled ?? true} onChange={(e) => setForm((f) => ({
...f,
autoArchiveDoneTasksEnabled: e.target.checked,
}))}/>{t("settings.scheduling.enableAutomaticTaskArchiving", " Enable automatic task archiving ")}</label>
<small>{t("settings.scheduling.completedTasksOlderThanTheThresholdAreMoved", "Completed tasks older than the threshold are moved out of the active task database.")}</small>
</div>
<div className="form-group">
<label htmlFor="autoArchiveDoneAfterMs">Archive Completed Tasks After (days)</label>
<input
id="autoArchiveDoneAfterMs"
type="number"
min={1}
step={1}
value={form.autoArchiveDoneAfterMs !== undefined ? Math.round(form.autoArchiveDoneAfterMs / MS_PER_DAY) : AUTO_ARCHIVE_DEFAULT_AFTER_DAYS}
onChange={(e) => {
<label htmlFor="autoArchiveDoneAfterMs">{t("settings.scheduling.archiveCompletedTasksAfterDays", "Archive Completed Tasks After (days)")}</label>
<input id="autoArchiveDoneAfterMs" type="number" min={1} step={1} value={form.autoArchiveDoneAfterMs !== undefined ? Math.round(form.autoArchiveDoneAfterMs / MS_PER_DAY) : AUTO_ARCHIVE_DEFAULT_AFTER_DAYS} onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({
...f,
autoArchiveDoneAfterMs: val === "" ? undefined : num * MS_PER_DAY,
...f,
autoArchiveDoneAfterMs: val === "" ? undefined : num * MS_PER_DAY,
}));
}}
disabled={form.autoArchiveDoneTasksEnabled === false}
/>
<small>Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).</small>
}} disabled={form.autoArchiveDoneTasksEnabled === false}/>
<small>{t("settings.scheduling.numberOfDaysATaskCanStayIn", "Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).")}</small>
</div>
<div className="form-group">
<label htmlFor="archiveAgentLogMode">Archive Agent Log</label>
<select
id="archiveAgentLogMode"
value={form.archiveAgentLogMode ?? "compact"}
onChange={(e) =>
setForm((f) => ({
...f,
archiveAgentLogMode: e.target.value as "none" | "compact" | "full",
}))
}
disabled={form.autoArchiveDoneTasksEnabled === false}
>
<option value="compact">Compact summary and recent entries</option>
<option value="none">Do not archive agent logs</option>
<option value="full">Full agent log</option>
<label htmlFor="archiveAgentLogMode">{t("settings.scheduling.archiveAgentLog", "Archive Agent Log")}</label>
<select id="archiveAgentLogMode" value={form.archiveAgentLogMode ?? "compact"} onChange={(e) => setForm((f) => ({
...f,
archiveAgentLogMode: e.target.value as "none" | "compact" | "full",
}))} disabled={form.autoArchiveDoneTasksEnabled === false}>
<option value="compact">{t("settings.scheduling.compactSummaryAndRecentEntries", "Compact summary and recent entries")}</option>
<option value="none">{t("settings.scheduling.doNotArchiveAgentLogs", "Do not archive agent logs")}</option>
<option value="full">{t("settings.scheduling.fullAgentLog", "Full agent log")}</option>
</select>
<small>Compact mode keeps archive size low while preserving recent agent activity for context.</small>
<small>{t("settings.scheduling.compactModeKeepsArchiveSizeLowWhilePreserving", "Compact mode keeps archive size low while preserving recent agent activity for context.")}</small>
</div>
<div className="form-group">
<label htmlFor="maxStuckKills">Max Stuck Retries</label>
<input
id="maxStuckKills"
type="number"
min={1}
step={1}
value={form.maxStuckKills ?? ""}
onChange={(e) => {
<label htmlFor="maxStuckKills">{t("settings.scheduling.maxStuckRetries", "Max Stuck Retries")}</label>
<input id="maxStuckKills" type="number" min={1} step={1} value={form.maxStuckKills ?? ""} onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, maxStuckKills: val && num > 0 ? num : undefined }));
}}
/>
<small>Maximum stuck-detector retries before a task is marked failed. Default: 6.</small>
}}/>
<small>{t("settings.scheduling.maximumStuckDetectorRetriesBeforeATaskIs", "Maximum stuck-detector retries before a task is marked failed. Default: 6.")}</small>
</div>
<div className="form-group">
<label htmlFor="groupOverlappingFiles" className="checkbox-label">
<input
id="groupOverlappingFiles"
type="checkbox"
checked={form.groupOverlappingFiles}
onChange={(e) =>
setForm((f) => ({ ...f, groupOverlappingFiles: e.target.checked }))
}
/>
Serialize tasks with overlapping files
</label>
<small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small>
<input id="groupOverlappingFiles" type="checkbox" checked={form.groupOverlappingFiles} onChange={(e) => setForm((f) => ({ ...f, groupOverlappingFiles: e.target.checked }))}/>{t("settings.scheduling.serializeTasksWithOverlappingFiles", " Serialize tasks with overlapping files ")}</label>
<small>{t("settings.scheduling.whenEnabledTasksThatModifyTheSameFiles", "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts")}</small>
</div>
<div className="form-group settings-overlap-ignore-group">
<label>Ignored overlap paths</label>
<small>
Optional file or directory paths to ignore when overlap serialization is enabled.
Paths are project-relative (for example <code>docs/</code> or <code>generated/*</code>).
<label>{t("settings.scheduling.ignoredOverlapPaths", "Ignored overlap paths")}</label>
<small>{t("settings.scheduling.optionalFileOrDirectoryPathsToIgnoreWhen", " Optional file or directory paths to ignore when overlap serialization is enabled. Paths are project-relative (for example ")}<code>docs/</code>{t("settings.scheduling.or", " or ")}<code>generated/*</code>{t("settings.scheduling.closeParenPeriod", ").")}
</small>
<div className="settings-overlap-ignore-list">
{(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => (
<div key={`overlap-ignore-${index}`} className="settings-overlap-ignore-row">
{(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => (<div key={`overlap-ignore-${index}`} className="settings-overlap-ignore-row">
<div className="settings-overlap-ignore-path-controls">
<input
type="text"
value={path}
placeholder="docs/"
onChange={(e) => onOverlapIgnorePathChange(index, e.target.value)}
/>
<button
type="button"
className="btn btn-sm"
onClick={() => onOpenOverlapPathPicker(index)}
aria-label={`Browse path for ignored overlap entry ${index + 1}`}
>
Browse
</button>
<input type="text" value={path} placeholder={t("settings.scheduling.docs", "docs/")} onChange={(e) => onOverlapIgnorePathChange(index, e.target.value)}/>
<button type="button" className="btn btn-sm" onClick={() => onOpenOverlapPathPicker(index)} aria-label={`Browse path for ignored overlap entry ${index + 1}`}>{t("settings.scheduling.browse", " Browse ")}</button>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => onRemoveOverlapIgnorePath(index)}
disabled={(form.overlapIgnorePaths ?? []).length === 0 && index === 0}
>
Remove
</button>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => onRemoveOverlapIgnorePath(index)} disabled={(form.overlapIgnorePaths ?? []).length === 0 && index === 0}>{t("settings.scheduling.remove", " Remove ")}</button>
</div>))}
</div>
<button
type="button"
className="btn btn-sm"
onClick={onAddOverlapIgnorePath}
>
Add ignored path
</button>
<button type="button" className="btn btn-sm" onClick={onAddOverlapIgnorePath}>{t("settings.scheduling.addIgnoredPath", " Add ignored path ")}</button>
</div>
<div className="settings-section-divider" />
<div className="settings-section-divider"/>
<h5 className="settings-section-heading">Step Execution</h5>
<MovedSettingsStub
message={t(
"settings.movedStub.stepExecution",
"Step execution settings (run steps in new sessions, max parallel steps) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
</>
);
<h5 className="settings-section-heading">{t("settings.scheduling.stepExecution", "Step Execution")}</h5>
<MovedSettingsStub message={t("settings.movedStub.stepExecution", "Step execution settings (run steps in new sessions, max parallel steps) now live on the workflow.")} onOpenWorkflowSettings={onOpenWorkflowSettings}/>
</>);
}
export default SchedulingSection;

View File

@@ -1,115 +1,50 @@
/**
* Worktrees section (U9 / KTD-10).
*
* Project-scoped worktree limits/naming/dir, pre-merge rebase options, and the
* Worktrunk integration block (install affordance + binary path + failure mode).
* The worktrunk install status hook result is owned by the shell (its
* `installed` flag also gates the save flow) and relayed as props alongside the
* fetched git-remotes list, the worktrees-dir picker, and the approvals opener.
* Keys, conditional disabling, and the install-state affordance markup are
* preserved verbatim from the original inline JSX.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { GitRemoteDetailed } from "../../../api";
import type { useWorktrunkInstallStatus } from "../../../hooks/useWorktrunkInstallStatus";
import type { SectionBaseProps, SettingsFormState } from "./context";
export interface WorktreesSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
gitRemotes: GitRemoteDetailed[];
worktrunkInstall: ReturnType<typeof useWorktrunkInstallStatus>;
worktrunkInstallVerified: boolean;
onOpenWorktreesDirPicker: () => void;
onOpenApprovals?: (approvalId?: string) => void;
scopeBanner: ReactNode;
gitRemotes: GitRemoteDetailed[];
worktrunkInstall: ReturnType<typeof useWorktrunkInstallStatus>;
worktrunkInstallVerified: boolean;
onOpenWorktreesDirPicker: () => void;
onOpenApprovals?: (approvalId?: string) => void;
}
export function WorktreesSection({
scopeBanner,
form,
setForm,
gitRemotes,
worktrunkInstall,
worktrunkInstallVerified,
onOpenWorktreesDirPicker,
onOpenApprovals,
}: WorktreesSectionProps) {
const { t } = useTranslation("app");
return (
<>
export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onOpenApprovals, }: WorktreesSectionProps) {
const { t } = useTranslation("app");
return (<>
{scopeBanner}
<h4 className="settings-section-heading">Worktrees</h4>
<h4 className="settings-section-heading">{t("settings.worktrees.worktrees", "Worktrees")}</h4>
<div className="form-group">
<label htmlFor="maxWorktrees">Max Worktrees</label>
<input
id="maxWorktrees"
type="number"
min={1}
max={20}
value={form.maxWorktrees ?? ""}
onChange={(e) => {
<label htmlFor="maxWorktrees">{t("settings.worktrees.maxWorktrees", "Max Worktrees")}</label>
<input id="maxWorktrees" type="number" min={1} max={20} value={form.maxWorktrees ?? ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
<small>Limits total git worktrees including in-review tasks</small>
}}/>
<small>{t("settings.worktrees.limitsTotalGitWorktreesIncludingInReviewTasks", "Limits total git worktrees including in-review tasks")}</small>
</div>
<div className="form-group">
<label htmlFor="worktreeInitCommand">Worktree Init Command</label>
<input
id="worktreeInitCommand"
type="text"
placeholder="pnpm install --frozen-lockfile"
value={form.worktreeInitCommand || ""}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeInitCommand: e.target.value }))
}
/>
<small>Shell command to run in each new worktree after creation</small>
<label htmlFor="worktreeInitCommand">{t("settings.worktrees.worktreeInitCommand", "Worktree Init Command")}</label>
<input id="worktreeInitCommand" type="text" placeholder={t("settings.worktrees.pnpmInstallFrozenLockfile", "pnpm install --frozen-lockfile")} value={form.worktreeInitCommand || ""} onChange={(e) => setForm((f) => ({ ...f, worktreeInitCommand: e.target.value }))}/>
<small>{t("settings.worktrees.shellCommandToRunInEachNewWorktree", "Shell command to run in each new worktree after creation")}</small>
</div>
<div className="form-group">
<label htmlFor="recycleWorktrees" className="checkbox-label">
<input
id="recycleWorktrees"
type="checkbox"
checked={form.recycleWorktrees}
onChange={(e) =>
setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))
}
/>
Recycle worktrees
</label>
<small>Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup</small>
<input id="recycleWorktrees" type="checkbox" checked={form.recycleWorktrees} onChange={(e) => setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))}/>{t("settings.worktrees.recycleWorktrees", " Recycle worktrees ")}</label>
<small>{t("settings.worktrees.offByDefaultOptInWhenEnabledCompleted", "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup")}</small>
</div>
<div className="form-group">
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
<input
id="executorAllowSiblingBranchRename"
type="checkbox"
checked={form.executorAllowSiblingBranchRename === true}
onChange={(e) =>
setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))
}
/>
Allow silent sibling branch rename during executor conflicts
</label>
<small>
Discouraged. This restores the legacy behavior where a live <code>fusion/&lt;task-id&gt;</code> branch collision silently forks work onto sibling branches like <code>-2</code> and can hide prior commits from the default recovery flow.
</small>
<input id="executorAllowSiblingBranchRename" type="checkbox" checked={form.executorAllowSiblingBranchRename === true} onChange={(e) => setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))}/>{t("settings.worktrees.allowSilentSiblingBranchRenameDuringExecutorConflicts", " Allow silent sibling branch rename during executor conflicts ")}</label>
<small>{t("settings.worktrees.discouragedThisRestoresTheLegacyBehaviorWhereA", " Discouraged. This restores the legacy behavior where a live ")}<code>fusion/&lt;task-id&gt;</code>{t("settings.worktrees.branchCollisionSilentlyForksWorkOntoSiblingBranches", " branch collision silently forks work onto sibling branches like ")}<code>-2</code>{t("settings.worktrees.andCanHidePriorCommitsFromTheDefault", " and can hide prior commits from the default recovery flow. ")}</small>
</div>
<div className="form-group">
<label htmlFor="worktreeNaming">Worktree Naming Style</label>
<select
id="worktreeNaming"
value={form.worktreeNaming || "random"}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeNaming: e.target.value as "random" | "task-id" | "task-title" }))
}
disabled={form.recycleWorktrees}
>
<option value="random">Random names (e.g., swift-falcon)</option>
<option value="task-id">Task ID (e.g., FN-042)</option>
<option value="task-title">Task title (e.g., fix-login-bug)</option>
<label htmlFor="worktreeNaming">{t("settings.worktrees.worktreeNamingStyle", "Worktree Naming Style")}</label>
<select id="worktreeNaming" value={form.worktreeNaming || "random"} onChange={(e) => setForm((f) => ({ ...f, worktreeNaming: e.target.value as "random" | "task-id" | "task-title" }))} disabled={form.recycleWorktrees}>
<option value="random">{t("settings.worktrees.randomNamesEGSwiftFalcon", "Random names (e.g., swift-falcon)")}</option>
<option value="task-id">{t("settings.worktrees.taskIDEGFN042", "Task ID (e.g., FN-042)")}</option>
<option value="task-title">{t("settings.worktrees.taskTitleEGFixLoginBug", "Task title (e.g., fix-login-bug)")}</option>
</select>
<small>
{form.recycleWorktrees
@@ -118,206 +53,103 @@ export function WorktreesSection({
</small>
</div>
<div className="form-group">
<label htmlFor="worktreesDir">Worktrees Directory</label>
<label htmlFor="worktreesDir">{t("settings.worktrees.worktreesDirectory", "Worktrees Directory")}</label>
<div className="settings-overlap-ignore-path-controls">
<input
id="worktreesDir"
type="text"
placeholder="Defaults to .worktrees — leave empty unless overriding"
value={form.worktreesDir || ""}
disabled={form.worktrunk?.enabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, worktreesDir: e.target.value }))
}
/>
<button
type="button"
className="btn btn-sm"
onClick={onOpenWorktreesDirPicker}
aria-label="Browse worktrees directory"
disabled={form.worktrunk?.enabled === true}
>
Browse
</button>
<input id="worktreesDir" type="text" placeholder={t("settings.worktrees.defaultsToWorktreesLeaveEmptyUnlessOverriding", "Defaults to .worktrees \u2014 leave empty unless overriding")} value={form.worktreesDir || ""} disabled={form.worktrunk?.enabled === true} onChange={(e) => setForm((f) => ({ ...f, worktreesDir: e.target.value }))}/>
<button type="button" className="btn btn-sm" onClick={onOpenWorktreesDirPicker} aria-label={t("settings.worktrees.browseWorktreesDirectory", "Browse worktrees directory")} disabled={form.worktrunk?.enabled === true}>{t("settings.worktrees.browse", " Browse ")}</button>
</div>
<small>
{form.worktrunk?.enabled === true
? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory."
: <>
Optional. Supports <code>~</code> and <code>{"{repo}"}</code>. Defaults to <code>&lt;projectRoot&gt;/.worktrees</code> when unset. Only affects newly-created worktrees.
</>}
: <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Defaults to ")}<code>&lt;projectRoot&gt;/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>}
</small>
</div>
<div className="form-group">
<label htmlFor="worktreeRebaseBeforeMerge" className="checkbox-label">
<input
id="worktreeRebaseBeforeMerge"
type="checkbox"
checked={form.worktreeRebaseBeforeMerge !== false}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: e.target.checked }))
}
/>
Rebase from remote before merge
</label>
<small>When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging — catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline.</small>
<input id="worktreeRebaseBeforeMerge" type="checkbox" checked={form.worktreeRebaseBeforeMerge !== false} onChange={(e) => setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: e.target.checked }))}/>{t("settings.worktrees.rebaseFromRemoteBeforeMerge", " Rebase from remote before merge ")}</label>
<small>{t("settings.worktrees.whenEnabledTheMergerFetchesFromTheConfigured", "When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging \u2014 catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline.")}</small>
</div>
{form.worktreeRebaseBeforeMerge !== false && (
<div className="form-group">
<label htmlFor="worktreeRebaseRemote">Rebase Remote</label>
<select
id="worktreeRebaseRemote"
value={form.worktreeRebaseRemote ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseRemote: e.target.value || undefined }))
}
>
<option value="">Use git default</option>
{gitRemotes.map((remote) => (
<option key={remote.name} value={remote.name}>
{form.worktreeRebaseBeforeMerge !== false && (<div className="form-group">
<label htmlFor="worktreeRebaseRemote">{t("settings.worktrees.rebaseRemote", "Rebase Remote")}</label>
<select id="worktreeRebaseRemote" value={form.worktreeRebaseRemote ?? ""} onChange={(e) => setForm((f) => ({ ...f, worktreeRebaseRemote: e.target.value || undefined }))}>
<option value="">{t("settings.worktrees.useGitDefault", "Use git default")}</option>
{gitRemotes.map((remote) => (<option key={remote.name} value={remote.name}>
{remote.name} ({remote.fetchUrl})
</option>
))}
</option>))}
</select>
<small>
Which remote to fetch for the pre-merge rebase. "Use git default" falls back to the remote configured for the default branch (typically <code>origin</code>).
<small>{t("settings.worktrees.whichRemoteToFetchForThePreMerge", " Which remote to fetch for the pre-merge rebase. \"Use git default\" falls back to the remote configured for the default branch (typically ")}<code>origin</code>{t("settings.worktrees.closeParenPeriod", ").")}
</small>
</div>
)}
</div>)}
<div className="form-group">
<label htmlFor="worktreeRebaseLocalBase" className="checkbox-label">
<input
id="worktreeRebaseLocalBase"
type="checkbox"
checked={form.worktreeRebaseLocalBase !== false}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseLocalBase: e.target.checked }))
}
/>
Also rebase onto local default-branch HEAD
</label>
<small>
In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow.
</small>
<input id="worktreeRebaseLocalBase" type="checkbox" checked={form.worktreeRebaseLocalBase !== false} onChange={(e) => setForm((f) => ({ ...f, worktreeRebaseLocalBase: e.target.checked }))}/>{t("settings.worktrees.alsoRebaseOntoLocalDefaultBranchHEAD", " Also rebase onto local default-branch HEAD ")}</label>
<small>{t("settings.worktrees.inAdditionToTheRemoteRebaseAboveAlso", " In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet \u2014 without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. ")}</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Worktrunk integration</h4>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.worktrees.worktrunkIntegration", "Worktrunk integration")}</h4>
<div className="form-group">
<label htmlFor="worktrunkEnabled" className="checkbox-label">
<input
id="worktrunkEnabled"
type="checkbox"
checked={form.worktrunk?.enabled === true}
disabled={!worktrunkInstallVerified && form.worktrunk?.enabled !== true}
onChange={(e) =>
setForm((f) => ({
...f,
worktrunk: {
enabled: e.target.checked,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))
}
/>
Enable worktrunk integration
</label>
<small>
Disabled by default (opt-in). When enabled, Fusion shells out to <code>worktrunk</code> for worktree create, sync, prune, and remove operations and follows worktrunk&apos;s directory layout.
</small>
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (
<small className="settings-muted">Install the worktrunk binary below to enable this integration.</small>
)}
<input id="worktrunkEnabled" type="checkbox" checked={form.worktrunk?.enabled === true} disabled={!worktrunkInstallVerified && form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
...f,
worktrunk: {
enabled: e.target.checked,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))}/>{t("settings.worktrees.enableWorktrunkIntegration", " Enable worktrunk integration ")}</label>
<small>{t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}<code>worktrunk</code>{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk&apos;s directory layout. ")}</small>
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (<small className="settings-muted">{t("settings.worktrees.installTheWorktrunkBinaryBelowToEnableThis", "Install the worktrunk binary below to enable this integration.")}</small>)}
</div>
<div className="form-group" data-testid="worktrunk-install-affordance">
{worktrunkInstall.status === "installed" && (
<small className="settings-muted">
worktrunk {worktrunkInstall.version ?? ""} installed at {worktrunkInstall.installPath ?? "~/.fusion/bin/worktrunk"}
</small>
)}
{(worktrunkInstall.status === "missing" || worktrunkInstall.status === "installing") && (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => void worktrunkInstall.requestInstall()}
disabled={worktrunkInstall.requesting || worktrunkInstall.status === "installing"}
>
{worktrunkInstall.status === "installed" && (<small className="settings-muted">{t("settings.worktrees.worktrunk", " worktrunk ")}{worktrunkInstall.version ?? ""}{t("settings.worktrees.installedAt", " installed at ")}{worktrunkInstall.installPath ?? "~/.fusion/bin/worktrunk"}
</small>)}
{(worktrunkInstall.status === "missing" || worktrunkInstall.status === "installing") && (<>
<button type="button" className="btn btn-primary" onClick={() => void worktrunkInstall.requestInstall()} disabled={worktrunkInstall.requesting || worktrunkInstall.status === "installing"}>
{t("settings.worktrees.installWorktrunk", "Install worktrunk binary")}
</button>
<small className="settings-muted">Enable worktrunk and request approval to install the pinned release.</small>
</>
)}
{worktrunkInstall.status === "pending-approval" && (
<>
<small className="settings-muted">{t("settings.worktrees.enableWorktrunkAndRequestApprovalToInstallThe", "Enable worktrunk and request approval to install the pinned release.")}</small>
</>)}
{worktrunkInstall.status === "pending-approval" && (<>
<small className="settings-muted">{t("settings.worktrees.awaitingApproval", "Awaiting approval — open Approvals to continue.")}</small>
<button
type="button"
className="btn btn-secondary"
onClick={() => onOpenApprovals?.(worktrunkInstall.pendingApprovalId)}
>
<button type="button" className="btn btn-secondary" onClick={() => onOpenApprovals?.(worktrunkInstall.pendingApprovalId)}>
{t("settings.worktrees.openApprovals", "Open Approvals")}
</button>
</>
)}
{(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && (
<>
</>)}
{(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && (<>
<small style={{ color: "var(--color-error)" }}>{worktrunkInstall.error ?? "Worktrunk install failed."}</small>
<button type="button" className="btn btn-secondary" onClick={() => void worktrunkInstall.requestInstall()}>
{t("settings.worktrees.tryAgain", "Try again")}
</button>
</>
)}
</>)}
</div>
<div className="form-group">
<label htmlFor="worktrunkBinaryPath">Worktrunk binary path</label>
<input
id="worktrunkBinaryPath"
type="text"
className="input"
placeholder="auto-detect (~/.fusion/bin/worktrunk or $PATH)"
value={form.worktrunk?.binaryPath ?? ""}
disabled={form.worktrunk?.enabled !== true}
onChange={(e) =>
setForm((f) => ({
...f,
worktrunk: {
<label htmlFor="worktrunkBinaryPath">{t("settings.worktrees.worktrunkBinaryPath", "Worktrunk binary path")}</label>
<input id="worktrunkBinaryPath" type="text" className="input" placeholder={t("settings.worktrees.autoDetectFusionBinWorktrunkOrPATH", "auto-detect (~/.fusion/bin/worktrunk or $PATH)")} value={form.worktrunk?.binaryPath ?? ""} disabled={form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: e.target.value,
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))
}
/>
<small>Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.</small>
},
}))}/>
<small>{t("settings.worktrees.optionalLeaveBlankToAutoResolveFusionWill", "Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.")}</small>
</div>
<div className="form-group">
<label htmlFor="worktrunkOnFailure">Worktrunk failure behavior</label>
<select
id="worktrunkOnFailure"
className="select"
value={form.worktrunk?.onFailure ?? "fail"}
disabled={form.worktrunk?.enabled !== true}
onChange={(e) =>
setForm((f) => ({
...f,
worktrunk: {
<label htmlFor="worktrunkOnFailure">{t("settings.worktrees.worktrunkFailureBehavior", "Worktrunk failure behavior")}</label>
<select id="worktrunkOnFailure" className="select" value={form.worktrunk?.onFailure ?? "fail"} disabled={form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: e.target.value as "fail" | "fallback-native",
},
}))
}
>
<option value="fail">Fail and pause the task (default)</option>
<option value="fallback-native">Fall back to Fusion's native worktree backend</option>
},
}))}>
<option value="fail">{t("settings.worktrees.failAndPauseTheTaskDefault", "Fail and pause the task (default)")}</option>
<option value="fallback-native">{t("settings.worktrees.fallBackToFusionsNativeWorktreeBackend", "Fall back to Fusion's native worktree backend")}</option>
</select>
<small>
<code>fail</code> stops on worktrunk errors for explicit operator recovery; <code>fallback-native</code> keeps progress moving by switching to Fusion&apos;s built-in worktree backend.
</small>
<code>fail</code>{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}<code>fallback-native</code>{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion&apos;s built-in worktree backend. ")}</small>
</div>
</>
);
</>);
}
export default WorktreesSection;

View File

@@ -872,6 +872,7 @@
"resetDayMonthly": "Day of month (1–28)",
"resetDayWeekly": "Day of week (0=Sun)",
"resetting": "Resetting...",
"resizeSidebar": "Resize agents sidebar",
"result": "Result",
"resultCreated_one": "{{count}} created",
"resultCreated_other": "{{count}} created",
@@ -1907,7 +1908,6 @@
"title": "Create room"
},
"dashboard": {
"brandName": "Fusion",
"initializingDashboard": "Initializing dashboard...",
"loadingMessage": "Loading Fusion dashboard",
"loadingProgress": "Dashboard loading progress",
@@ -5458,10 +5458,18 @@
"cancel": "Cancel",
"save": "Save"
},
"agentPermissions": {
"agentPermissions": "Agent Permissions",
"agentProvisioningApprovals": "Agent Provisioning Approvals",
"configureProjectLevelApprovalBehaviorForDurableProvisioning": " Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete). ",
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Per-agent settings override project defaults. Each category controls a separate approval gate."
},
"appearance": {
"hideAISessionNotificationBanners": "Hide AI session notification banners",
"language": "Language",
"languageAuto": "Auto",
"languageAutoHint": "Follow the browser language",
"suppressTheLdquoNeedsYourInputRdquoBanner": " Suppress the &ldquo;needs your input&rdquo; banner that appears when AI sessions are awaiting input or have failed. ",
"title": "Appearance"
},
"auth": {
@@ -5501,10 +5509,51 @@
"title": "Authentication",
"waitingForLogin": "Waiting for login…"
},
"authentication": {
"enterAPIKey": "Enter API key",
"key": "Key: "
},
"backups": {
"02": "0 2 * * *",
"03": "0 3 * * *",
"agentsOnlyFusionAgentMemory": "Agents only (.fusion/agent-memory)",
"allProjectAgents": "All (project + agents)",
"and": "...and ",
"backupCreated": "Backup created successfully",
"backupDirectory": "Backup Directory",
"backupNow": "Backup Now",
"creating": "Creating…"
"backups": "backups",
"backupS": " backup(s)",
"backupScheduleCron": "Backup Schedule (Cron)",
"creating": "Creating…",
"cronExpressionForBackupTimingDefault02": " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) ",
"cronExpressionForMemoryBackupTimingDefault0": "Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).",
"currentBackups": "Current Backups",
"databaseBackups": "Database Backups",
"directoryForBackupFilesRelativeToProjectRoot": "Directory for backup files, relative to project root",
"directoryForMemoryBackupsRelativeToProjectRoot": "Directory for memory backups, relative to project root.",
"enableAutomaticDatabaseBackups": " Enable automatic database backups ",
"enableAutomaticMemoryBackups": " Enable automatic memory backups ",
"fusionBackups": ".fusion/backups",
"fusionBackupsMemory": ".fusion/backups/memory",
"invalidCronExpressionFormat": "Invalid cron expression format",
"loadingBackupInfo": "Loading backup info…",
"memoryBackupDirectory": "Memory Backup Directory",
"memoryBackups": "Memory Backups",
"memoryBackupScheduleCron": "Memory Backup Schedule (Cron)",
"memoryBackupScope": "Memory Backup Scope",
"memoryRetentionCount": "Memory Retention Count",
"more": " more",
"mustBeBetween1And100": "Must be between 1 and 100",
"numberOfBackupFilesToKeepOldestAre": "Number of backup files to keep (oldest are deleted first). Range: 1-100.",
"numberOfMemoryBackupsToKeepOldestAre": "Number of memory backups to keep (oldest are deleted first). Range: 1-100.",
"pathCannotContainParentDirectoryTraversal": "Path cannot contain parent directory traversal (..)",
"projectOnlyFusionMemory": "Project only (.fusion/memory)",
"retentionCount": "Retention Count",
"totalSize": "total size",
"view": "View ",
"whenEnabledProjectAndAgentMemoryFilesAre": "When enabled, project and agent memory files are backed up automatically on a schedule.",
"whenEnabledTheDatabaseIsBackedUpAutomatically": "When enabled, the database is backed up automatically on a schedule"
},
"clearToDefault": "Reset to default",
"cliAgents": {
@@ -5531,14 +5580,80 @@
"saveFailed": "Failed to save CLI agent settings"
},
"closeModal": "Close conflict modal",
"commands": {
"buildCommand": "Build Command",
"commands": "Commands",
"commandUsedToBuildTheProjectInjectedInto": "Command used to build the project — injected into generated task specs",
"commandUsedToRunTestsInjectedIntoGenerated": "Command used to run tests — injected into generated task specs",
"eGPnpmBuild": "e.g. pnpm build",
"eGPnpmTest": "e.g. pnpm test",
"testCommand": "Test Command"
},
"conflictModalTitle": "Resolve Settings Conflicts",
"experimental": {
"experimentalFeatures": "Experimental Features",
"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": "Feature Flags"
},
"footer": {
"help": "Help",
"version": "Version {{version}}"
},
"general": {
"25": "25",
"200": "200",
"3000": "3000",
"14Days": "14 days",
"30Days": "30 days",
"60Days": "60 days",
"7Days": "7 days",
"90Days": "90 days",
"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. ",
"autoCleanupOldChats": "Auto-cleanup old chats",
"autoPruneOldMail": "Auto-prune old mail",
"bannerFiresWhenTodoCountIsStrictlyGreater": "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.",
"builtInWorkflows": "Built-in workflows",
"capacityRiskBanner": "Capacity Risk Banner",
"chatHistory": "Chat history",
"chatRooms": "Chat Rooms",
"completionDocumentationAutomation": "Completion Documentation Automation",
"controlsHowFutureTaskSpecsHandleReleaseNote": " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ",
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ",
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.",
"defaultTrackingModeForNewTasks": "Default tracking mode for new tasks",
"deleteChatSessionsAndRoomsThatHaveBeen": "Delete chat sessions and rooms that have been idle for this many days. Default: Off.",
"deleteInboxOutboxMessagesOlderThanThisMany": "Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.",
"disabledBuiltInWorkflowsAreHiddenFromWorkflow": "Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve.",
"enableSummarizationInProjectModelsToConfigureThatModel": " Enable summarization in Project Models to configure that model.",
"fN": "FN",
"general": "General",
"gitHubTracking": "GitHub Tracking",
"hardCapOnTheSynthesizedEarlierRoomContext": "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000.",
"learnMore": "Learn more",
"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. ",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "New tasks inherit this custom workflow's steps (overridable per task)",
"numberOfMostRecentChatRoomMessagesKept": "Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.",
"off": "Off",
"offDefault": "Off (default)",
"onForNewTasks": "On for new tasks",
"operationalLogRetention": "Operational log retention",
"ownerRepo": "owner/repo",
"prefixForNewTaskIDsEGKB": "Prefix for new task IDs (e.g. KB, PROJ)",
"prefixMustBe110UppercaseLetters": "Prefix must be 1–10 uppercase letters",
"projectDefaultTrackingRepo": "Project default tracking repo",
"recentVerbatimRoomMessages": "Recent verbatim room messages",
"requireChangelogUpdateExistingChangelog": "Require changelog update (existing changelog)",
"requireChangesetChangesetMd": "Require changeset (.changeset/*.md)",
"roomCompactionFetchLimit": "Room compaction fetch limit",
"roomSummaryMaxCharacters": "Room summary max characters",
"searchTheTrackingRepoForLikelyDuplicatesBefore": " Search the tracking repo for likely duplicates before opening a new issue ",
"settingsSaved": "Settings saved",
"showCapacityRiskBanner": " Show capacity risk banner ",
"showQuickChatButton": " Show quick chat button ",
"showTheFloatingChatButtonInTheDashboard": "Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.",
"taskPrefix": "Task Prefix",
"todoThreshold": "Todo threshold",
"trackingIssuesUseThisTaskAposSTitle": " Tracking issues use this task&apos;s title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. ",
"updateAvailablePrefix": "v{{version}} available",
"updateCheckFailed": "Failed to check for updates",
"updateFailed": "Update failed",
@@ -5547,7 +5662,88 @@
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
"updating": "Updating…",
"upToDate": "You're up to date ✓"
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
"upToDate": "You're up to date ✓",
"useEphemeralTaskWorkerAgents": " Use ephemeral task-worker agents ",
"warnOnTheBoardWhenTodoWorkExceeds": "Warn on the board when todo work exceeds the threshold and no idle agents are available.",
"whenEnabledDefaultFusionSpawnsShortLived": " When enabled (default), Fusion spawns short-lived ",
"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. ",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": " workflows, or changelog mode when contributors should update an existing changelog file. "
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. ",
"autoReloadDashboardOnVersionChange": " Auto-reload dashboard on version change ",
"checkForThe": " Check for the ",
"checkForUpdatesAutomatically": " Check for updates automatically ",
"cLIBinaryOnPATH": " CLI binary on PATH ",
"cLIBySpawning": " CLI by spawning",
"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. ",
"dailyRecommended": "Daily (recommended)",
"disableThisIfYourLocalDevProcessIs": ". Disable this if your local dev process is the source of truth and you don&apos;t want any outdated globally-installed binary executed during the probe. ",
"frequency": "Frequency",
"general": "General",
"globalDefaultTrackingRepo": "Global default tracking repo",
"leaveBothThinkingTogglesOffToKeepThe": " Leave both thinking toggles off to keep the original default behavior. This only controls persisted ",
"manualOnlyNeverAutoCheck": "Manual only — never auto-check",
"onStartupOncePerServerLaunch": "On startup — once per server launch",
"ownerRepo": "owner/repo",
"projectsInheritThisValueWhenTheyDoNot": "Projects inherit this value when they do not set a project default tracking repo.",
"rowsAndDoesNotAffectAssistantTextOr": " rows and does not affect assistant text or tool rows. ",
"saveAIThinkingForEphemeralTaskWorkerAgents": " Save AI thinking for ephemeral / task-worker agents ",
"saveAIThinkingForPermanentAgents": " Save AI thinking for permanent agents ",
"saveAIThinkingLogs": "Save AI thinking logs",
"saveToolOutputInAgentLogs": " Save tool output in agent logs ",
"updates": "Updates",
"weekly": "Weekly",
"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. ",
"whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": " 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. ",
"whenEnabledFusionChecksNpmForNewVersions": " When enabled, Fusion checks npm for new versions of",
"whenEnabledTheDashboardProbesForAGlobally": " When enabled, the dashboard probes for a globally-installed"
},
"globalModels": {
"allow": "allow",
"commaSeparatedValuesSentToOpenRouterModelSync": "Comma-separated values sent to OpenRouter model sync.",
"controlsHowMuchReasoningEffortTheAIModel": "Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.",
"default": "Default",
"default2": "default",
"defaultAIModelUsedForTaskExecutionWhen": "Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.",
"defaultModel": "Default Model",
"deny": "deny",
"fallbackModel": "Fallback Model",
"flowAndPublishesThemUnderTheOpencodeGo": " flow and publishes them under the opencode-go provider in model pickers. ",
"fusion": "Fusion",
"globalBaselineModelsForEachAIRoleProject": " Global baseline models for each AI role. Project settings can override these per-project. ",
"httpsRunfusionAi": "https://runfusion.ai",
"latency": "latency",
"leaveEmptyToOmitThisHeaderDefaultFusion": "Leave empty to omit this header. Default: Fusion.",
"leaveEmptyToOmitThisHeaderDefaultHttps": "Leave empty to omit this header. Default: https://runfusion.ai.",
"modelLanes": "Model Lanes",
"noFallback": "No fallback",
"openaiAnthropic": "openai, anthropic",
"openRouterAdvanced": "OpenRouter advanced",
"openRouterAllowFallbacks": "OpenRouter allow fallbacks",
"openRouterHTTPReferer": "OpenRouter HTTP-Referer",
"openRouterOutputModalitiesFilter": "OpenRouter output_modalities filter",
"openRouterRoutingIgnore": "OpenRouter routing ignore",
"openRouterRoutingOnly": "OpenRouter routing only",
"openRouterRoutingOrder": "OpenRouter routing order",
"openRouterRoutingSort": "OpenRouter routing sort",
"openRouterSupportedParametersFilter": "OpenRouter supported_parameters filter",
"openRouterXTitle": "OpenRouter X-Title",
"price": "price",
"providerName": "provider-name",
"requireParameters": " Require parameters ",
"startupModelSync": "Startup Model Sync",
"syncOpencodeGoModelListAtStartup": " Sync opencode-go model list at startup ",
"syncOpenRouterModelListAtStartup": " Sync OpenRouter model list at startup ",
"text": "text",
"thinkingEffort": "Thinking Effort",
"throughput": "throughput",
"toolsStructuredOutputs": "tools, structured_outputs",
"usedAutomaticallyIfThePrimaryDefaultModelHits": "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.",
"useDefault": "Use default",
"whenEnabledStartupFetchesTheLatestAvailableModels": " When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. ",
"whenEnabledStartupRefreshesModelsThroughTheLocal": " When enabled, startup refreshes models through the local "
},
"header": {
"discord": "Discord"
@@ -5567,17 +5763,179 @@
"keepRemote": "Keep Remote",
"loading": "Loading…",
"memory": {
"03": "0 3 * * *",
"agentsGetMemorySearchMemoryGetAndMemory": "Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.",
"agentsSearchWithQmdFirstFallBackTo": ". Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed. ",
"automaticallyCompactMemoryWhenItExceedsTheThreshold": "Automatically compact memory when it exceeds the threshold on a schedule",
"autoSummarizeMemory": " Auto-Summarize Memory ",
"bytesUpdated": " bytes · updated ",
"cannotSave": "Cannot save: ",
"checkingMemoryWriteAccess": "Checking memory write access...",
"compacting": "Compacting…",
"compactionThresholdChars": "Compaction Threshold (chars)",
"compactSelectedFile": "Compact Selected File",
"cronExpressionForAutoSummarizeScheduleDefaultDaily": "Cron expression for auto-summarize schedule (default: daily at 3 AM)",
"cronExpressionForDreamProcessing": "Cron expression for dream processing.",
"dreamCompleted": "Dream processing completed",
"dreaming": " Dreaming… ",
"dreamNow": "Dream Now",
"dreamSchedule": "Dream Schedule",
"enableMemoryTools": " Enable memory tools ",
"failedToLoadBackendStatus": "Failed to load backend status: ",
"for": "for \"",
"installing": "Installing…",
"installQmd": "Install qmd",
"loadingMemory": "Loading memory…",
"manuallyTriggerDreamProcessingNow": "Manually trigger dream processing now.",
"memory": "Memory",
"memoryCompacted": "Memory file compacted",
"memoryFile": "Memory File",
"memoryIsConfiguredWithAReadOnlyBackend": " Memory is configured with a read-only backend. You can view the file, but saving is disabled. ",
"memoryIsCurrentlyDisabledYouCanViewThe": " Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled. ",
"memoryLivesIn": " Memory lives in ",
"memorySaved": "Memory saved",
"memoryWillBeCompactedWhenItExceedsThis": "Memory will be compacted when it exceeds this character count",
"noMatchingMemoryFound": "No matching memory found.",
"processDreamsFromDailyMemory": " Process dreams from daily memory ",
"qmd": " qmd ",
"qmdIsNotInstalledSearchWillUseLocal": " qmd is not installed. Search will use local files. Install indexed retrieval: ",
"result": " result",
"runsTheSameQmdBackedMemorySearchPath": "Runs the same qmd-backed memory_search path agents use.",
"saveMemory": "Save Memory",
"scheduleCron": "Schedule (cron)",
"searchMemoryWithQmd": "Search memory with qmd",
"testing": "Testing…",
"testRetrieval": "Test Retrieval"
"testRetrieval": "Test Retrieval",
"turnsDailyNotesIntoDREAMSMdAndPromotes": "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md."
},
"merge": {
"abort": "Abort",
"abortOneAIAttemptRequireManualResolutionIf": "Abort — one AI attempt; require manual resolution if it fails",
"addFusionAsCoAuthorOnCommits": " Add Fusion as co-author on commits ",
"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). ",
"aICorrectiveRoundsBeforeLandingTheBestResult": "AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project&apos;s reviewer/validator model.",
"aIMerge": "AI merge",
"aIMergeDefaultAIMergesInAClean": "AI merge (default) — AI merges in a clean room, an AI reviewer audits with retries, then lands",
"aIModeMergesTheTaskBranchIntoAn": " AI mode merges the task branch into an isolated clean-room checkout at the target branch&apos;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. ",
"aIOnly": "AI only",
"aIOnlyAIAutoResolveAIRetryNever": "AI only — AI → auto-resolve → AI retry; never silently pick a side",
"allowAIMergeToSyncADirtyChecked": " Allow AI merge to sync a dirty checked-out integration branch ",
"alwaysPreserveDirectMergeCommitHistory": "Always preserve direct-merge commit history",
"alwaysSquashDirectMerges": "Always squash direct merges",
"and": ", and ",
"and2": " and ",
"areListedFirstOrChoose": " are listed first — or choose ",
"autoCompletionMode": "Auto-completion mode",
"autoDetect": "auto-detect",
"autoDetectOriginHEADMain": "(auto-detect — origin/HEAD → main)",
"autoKeepsTodayAposSSquashBehaviorFor": " Auto keeps today&apos;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 ",
"autoMergeCompletedTasks": " Auto-merge completed tasks ",
"autoMergeConflictRetries": "Auto-merge conflict retries",
"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. ",
"autoMergeWillRunRebaseConflictResolutionAnd": " Auto-merge will run rebase, conflict resolution, and squash commits inside the project root (the user&apos;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&apos;t, merges may fail or touch the user&apos;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). ",
"autoResolveConflictsInLockFilesAndGenerated": " Auto-resolve conflicts in lock files and generated files ",
"autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive": "Auto — squash single-substantive branches, preserve multi-substantive history",
"autoSyncProjectCheckoutAfterMerge": "Auto-sync project checkout after merge",
"block": "Block",
"blockStrict": "Block (strict)",
"both": " Both ",
"branchName": "branch name",
"checkingForLegacyAutoMergeStamps": "Checking for legacy auto-merge stamps…",
"coAuthorEmail": "Co-author Email",
"coAuthorName": "Co-author Name",
"conflictFallbackStrategy": "Conflict Fallback Strategy",
"controlsThePostMergeAuditGate": " Controls the post-merge audit gate. ",
"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. ",
"createMonitorAndMergeAGitHubPullRequest": "Create, monitor, and merge a GitHub pull request",
"custom": "Custom…",
"dangerousCompatibilityEscapeHatchLeaveOffUnlessYou": " 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. ",
"defaultLogsFindingsButAutoCompletesTheMerge": " (default) logs findings but auto-completes the merge. ",
"deterministicLegacyRebaseConflictStrategyAuditPipeline": "Deterministic (legacy) — rebase / conflict-strategy / audit pipeline",
"directMergeCommitRouting": "Direct merge commit routing",
"directMergeIntoTheCurrentBranch": "Direct merge into the current branch",
"emailUsedInThe": "Email used in the ",
"enableTestMode": " Enable test mode ",
"fastForwardOfLocalMainFrom": " + fast-forward of local main from ",
"fastForwardOnlySkipDirtyWorktrees": "Fast-forward only — skip dirty worktrees",
"finalFallback": "final fallback",
"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. ",
"flipOverlappingFilesToPreferTheTaskBranch": "Flip overlapping files to prefer the task branch (default)",
"forcesAllAILanesToUseTheDeterministic": "Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.",
"fusion": "Fusion",
"gitHubAuthentication": "GitHub Authentication",
"gitHubAuthMode": "GitHub auth mode",
"gitHubCLIGhAuth": "GitHub CLI (gh auth)",
"gitHubPersonalAccessToken": "GitHub personal access token",
"gitRemoteToPushToEGOrigin": "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".",
"ignoreOverlapDetectionPreserveLegacyBehavior": "Ignore overlap detection — preserve legacy behavior",
"includeTaskIDInCommitScope": " Include task ID in commit scope ",
"insteadOf": " instead of ",
"integrationBranch": "Integration branch",
"integrationWorktree": "Integration worktree",
"inYourProjectRootWillShowTheNew": " in your project root will show the new commits inverted as &quot;staged changes&quot; until you pull manually. Only applies to direct merges. ",
"isTheLegacyBehavior": " is the legacy behavior: ",
"isTheStricterOptInModeThatRefuses": " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ",
"legacy": " → legacy ",
"legacy2": "Legacy ",
"legacyAutoMergeStamp": " legacy auto-merge stamp",
"legacyAutoMergeStampCleanup": "Legacy auto-merge stamp cleanup",
"legacyIntegrationBranchMode": "Legacy integration-branch mode.",
"maxAIReviewPasses": "Max AI review passes",
"merge": "Merge",
"moreDetails": "More details",
"nameUsedInThe": "Name used in the ",
"noLegacyAutoMergeStampsToCleanUp": " No legacy auto-merge stamps to clean up. ",
"noreplyRunfusionAi": "noreply@runfusion.ai",
"off": "Off",
"offLeaveTheProjectRootStaleLegacyBehavior": "Off — leave the project root stale (legacy behavior)",
"offSkipAudit": "Off (skip audit)",
"optionsStartWithABestEffort": " options start with a best-effort ",
"origin": "origin",
"personalAccessToken": "Personal access token",
"pickALocalBranchFromTheDropdownCommon": "). Pick a local branch from the dropdown — common integration names like ",
"positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.",
"postMergeAuditMode": "Post-merge audit mode",
"pushRemote": "Push Remote",
"pushToRemoteAfterMerge": " Push to remote after merge ",
"readyToCleanUp": " ready to clean up.",
"retriesTheAIAgentRatherThanAutoPicking": " retries the AI agent rather than auto-picking a side. ",
"reuseTaskWorktreeDefault": "Reuse task worktree (default)",
"skipsTheAuditEntirelySwitchingToOffIs": " skips the audit entirely. Switching to Off is recommended only if you trust your branches don&apos;t silently drop edits. ",
"smart": "Smart",
"smartConflictResolution": " Smart conflict resolution ",
"smartPreferMain": "Smart, prefer main",
"smartPreferMainOnFallbackFetchFfOrigin": "Smart, prefer main on fallback — fetch+ff origin → AI → auto-resolve → -X ours (default; protects just-merged sibling work)",
"smartPreferMainOverlapGuard": "Smart Prefer Main Overlap Guard",
"smartPreferTask": "Smart, prefer task",
"smartPreferTaskOnFallbackFetchFfOrigin": "Smart, prefer task on fallback — fetch+ff origin → AI → auto-resolve → -X theirs (legacy \"smart\" behavior; task branch wins)",
"snapsCleanlyWhenTheWorktreeHasNoEdits": " snaps cleanly when the worktree has no edits and skips otherwise. ",
"snapshotsRealLocalEditsAsAPatchAgainst": " 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. ",
"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 ",
"soMainWinsProtectsJustMergedSiblingWork": " so main wins — protects just-merged sibling work and is the new default. ",
"soTheTaskBranchWinsFastButCan": " so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression). ",
"stashFastForwardDefaultPreserveLocalEdits": "Stash + fast-forward (default) — preserve local edits",
"stopsAfterTheFirstAIAttemptAndWaits": " stops after the first AI attempt and waits for a human. ",
"symbolicRefFallback": " symbolic ref → fallback ",
"theCanonicalBranchFusionMergesTasksIntoAnd": " The canonical branch Fusion merges tasks into and uses as the reference for all ahead/behind / overlap / pre-rebase computations. Leave on ",
"theLegacyMergeSettingsBelowDoNotApply": "The legacy merge settings below do not apply while AI merge is on.",
"toResolveViaTheStandardCascade": " to resolve via the standard cascade (",
"toTypeABranchThatDoesnAposT": " to type a branch that doesn&apos;t exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ",
"trailer": " trailer",
"trailerCreditingFusionRecognizedByGitHubForShared": " trailer crediting Fusion (recognized by GitHub for shared attribution). ",
"useDropdown": " Use dropdown ",
"useProjectRootLegacy": "Use project root (legacy)",
"uses": " uses ",
"valuesFromOlderSettingsAreMigratedAutomatically": " values from older settings are migrated automatically.",
"warn": "Warn",
"warnDefaultLogFindingsContinue": "Warn (default; log findings, continue)",
"warnOnlyKeepLegacyMainWinsFallback": "Warn only — keep legacy main-wins fallback",
"whenDisabledMergeCommitMessagesOmitTheTask": "When disabled, merge commit messages omit the task ID from the scope (e.g. ",
"whenEnabledCommitsMadeByFusionKeepYour": " When enabled, commits made by Fusion keep your git identity as the primary author and append a ",
"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.",
"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.",
"whenEnabledTasksThatPassReviewAreAutomatically": "When enabled, tasks that pass review are automatically merged into the main branch",
"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.",
"whenUsingSmartPreferMainAutomaticallyPreferThe": " When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. "
},
"mergeManually": "Merge Manually",
"mobileNav": {
@@ -5610,38 +5968,314 @@
"project": "Specific to this project"
}
},
"nodeRouting": {
"blockExecution": "Block execution",
"configureHowTasksAreRoutedToExecutionNodes": "Configure how tasks are routed to execution nodes.",
"defaultExecutionNode": "Default Execution Node",
"fallBackToLocal": "Fall back to local",
"localExecutionNoDefaultNode": "Local execution (no default node)",
"nodeRouting": "Node Routing",
"selectedNode": "Selected node:",
"theseSettingsApplyAtTheProjectLevel": "These settings apply at the project level.",
"unavailableNodePolicy": "Unavailable Node Policy",
"usedWhenATaskHasNoNodeOverride": "Used when a task has no node override. Node status is shown for safer routing selection."
},
"nodeSync": {
"alwaysAsk": "Always ask",
"automaticallySynchronizeSettingsBetweenThisNodeAndConnected": "Automatically synchronize settings between this node and connected remote nodes",
"conflictResolution": "Conflict resolution",
"enableAutomaticSettingsSync": " Enable automatic settings sync ",
"every15Minutes": "Every 15 minutes",
"every1Hour": "Every 1 hour",
"every30Minutes": "Every 30 minutes",
"every5Minutes": "Every 5 minutes",
"includeAPIKeysAndOAuthTokensInSync": "Include API keys and OAuth tokens in sync operations",
"keepLocal": "Keep local",
"keepRemote": "Keep remote",
"lastWriteWins": "Last write wins",
"nodeSync": "Node Sync",
"syncInterval": "Sync interval",
"syncModelAuthCredentials": " Sync model auth credentials ",
"workflowSettingsNotSynced": "Workflow settings are not synced across nodes yet."
},
"notifications": {
"accessTokenOptional": "Access token (optional)",
"advanced": "Advanced",
"allFailuresLegacy": "All failures (legacy)",
"baseURLForDeepLinksInNotificationsWhen": " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. ",
"customNtfyServerURLOptional": "Custom ntfy server URL (optional)",
"dashboardHostname": "Dashboard Hostname",
"discord": "Discord",
"enable": " Enable ",
"failureNotificationDelayMs": "Failure notification delay (ms)",
"failureNotificationMode": "Failure notification mode",
"format": "Format",
"general": " General: ",
"generic": "Generic",
"howLongAFailureMustPersistBeforeA": " How long a failure must persist before a push notification is sent. 0 = notify immediately. ",
"httpLocalhost3000": "http://localhost:3000",
"httpsHooksExampleCom": "https://hooks.example.com/...",
"httpsNtfySh": "https://ntfy.sh",
"learnMoreAboutNtfySh": " Learn more about ntfy.sh ",
"leaveBlankToKeepTheDefaultServerHttps": " Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. ",
"leaveBlankToPublishWithoutAuthenticationWhenSet": " Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. ",
"messageInbox": " Message inbox: ",
"mustBeAValidURLStartingWithHttp": " Must be a valid URL starting with http:// or https:// ",
"myTopicName": "my-topic-name",
"notifications": "Notifications",
"notifyOnEvents": "Notify on events",
"ntfy": "ntfy",
"ntfyTopic": "ntfy Topic",
"roomReply": " Room reply: ",
"sending": "Sending…",
"slack": "Slack",
"stickyFailuresOnlyDefault": "Sticky failures only (default)",
"stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits": "Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.",
"terminalFailuresOnlySuppressAutoRetried": "Terminal failures only (suppress auto-retried)",
"testMessageInbox": "Test message inbox",
"testNotification": "Test notification",
"testRoomReply": "Test room reply"
"testRoomReply": "Test room reply",
"tk": "tk_...",
"topicMustBe164AlphanumericHyphenOr": " Topic must be 1–64 alphanumeric, hyphen, or underscore characters ",
"webhook": "Webhook",
"webhookNotifications": " Webhook notifications ",
"webhookURL": "Webhook URL",
"yourNtfyShTopicName164Alphanumeric": " Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters)."
},
"plugins": {
"fusionPlugins": " Fusion Plugins ",
"piExtensions": " Pi Extensions ",
"pluginManagerType": "Plugin manager type",
"plugins": "Plugins"
},
"projectModels": {
"addPreset": " Add Preset ",
"advancedWorkflowPolicy": " Advanced workflow policy ",
"aIMergeCommitSummaries": " AI merge commit summaries ",
"aITitleAndGitCommitMessageSummarization": " AI Title and Git Commit Message Summarization ",
"automaticallyCompactContextWhenApproachingThisTokenCount": "Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count.",
"autoSelectPresetBasedOnTaskSize": " Auto-select preset based on task size ",
"autoSummarizeLongDescriptionsAsTitles": " Auto-summarize long descriptions as titles ",
"configuredPresets": "Configured presets",
"configuresTheModelUsedForTwoShortSummary": " Configures the model used for two short-summary jobs: auto-generating task titles from long descriptions, and generating merge commit summaries from step commits and diff stats. ",
"defaultWorkflowModelLaneActions": "Default workflow model lane actions",
"defaultWorkflowModelLanes": "Default workflow model lanes",
"delete": " Delete ",
"edit": " Edit ",
"executorModel": "Executor model",
"fallsBackTo": " Falls back to: ",
"loadingAvailableModels": "Loading available models…",
"loadingWorkflowModelLanes": "Loading workflow model lanes…",
"modelLanes": "Model Lanes",
"modelPresets": "Model Presets",
"name": "Name",
"noCap": "No cap",
"noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow": " No models available. Configure authentication before selecting workflow model lanes. ",
"noModelsAvailableConfigureAuthenticationFirst": " No models available. Configure authentication first. ",
"noModelsAvailableConfigureAuthenticationFirst2": "No models available. Configure authentication first.",
"noPreset": "No preset",
"noPresetsConfiguredYet": "No presets configured yet.",
"openAProjectToEditWorkflowModelLanes": "Open a project to edit workflow model lanes.",
"overrideGlobalModelSettingsAtTheProjectLevel": " Override global model settings at the project level. Each lane controls a specific AI usage context. Unset lanes inherit from the corresponding global lane. The Project Default Model is the fallback for this project when a more specific lane is unset. ",
"presetEditor": "Preset editor",
"reset": " Reset ",
"resetToDefaultNoCap": "Reset to default (no cap)",
"resetToInheritFromGlobal": "Reset to inherit from global",
"resetToInheritFromWorkflow": "Reset to inherit from workflow",
"reviewerModel": "Reviewer model",
"theseProjectOverridesApplyToTheActiveDefault": " These project overrides apply to the active default workflow. ",
"tokenCap": "Token Cap",
"useDefault": "Use default",
"useWorkflowDefault": "Use workflow default",
"whenEnabledMergeCommitMessagesIncludeAnAI": " When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model. ",
"whenEnabledTasksCreatedWithoutATitleBut": " When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters). The same model is also used to generate fallback merge commit message bodies when the branch's commit log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue titles when a tracked task has no title yet. "
},
"remote": {
"acceptRoutes": " Accept routes ",
"advancedNamedTunnel": "Advanced (Named Tunnel)",
"advancedSettings": "Advanced Settings",
"authenticatedURL": "Authenticated URL:",
"authLinks": "Auth Links",
"authLinkTokenType": "Auth link token type",
"automaticallyRestoreTunnelOnStartupIfItWas": "Automatically restore tunnel on startup if it was running when last stopped.",
"cloudflare": "Cloudflare",
"cloudflaredInstalled": "cloudflared installed successfully",
"cloudflaredIsInstalled": "cloudflared is installed",
"cloudflaredIsNotInstalled": "cloudflared is not installed",
"cloudflaredMustBeInstalledToStartTheTunnel": "cloudflared must be installed to start the tunnel",
"enableShortLivedTokens": " Enable short-lived tokens ",
"expiresAt": " · Expires at {{expiresAt}}",
"external": "External ",
"externalTunnelQRCode": "External tunnel QR code",
"generateQR": "Generate QR",
"generateShortLivedToken": "Generate short-lived token",
"httpsYourDomainExample": "https://your-domain.example",
"ifHomebrewIsUnavailable": "If Homebrew is unavailable: ",
"ingressURL": "Ingress URL",
"installCloudflared": "Install cloudflared",
"installing": "Installing…",
"lastShortLivedTokenExpiresAt": "Last short-lived token expires at ",
"manualInstall": "Manual install: ",
"ms": "ms)",
"namedTunnelModeEnabled": "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below.",
"noExpiry": " · No expiry",
"persistentToken": "Persistent token",
"persistentTokenRegenerated": "Persistent token regenerated",
"qRSVGMarkup": "QR SVG markup",
"regeneratePersistentToken": "Regenerate persistent token",
"rememberLastRunningState": " Remember last running state ",
"remoteAccess": "Remote Access",
"remoteAccessCode": "Remote access code:",
"remoteAccessQRCode": "Remote access QR code",
"remoteProvider": "Remote provider",
"restarting": "Restarting…",
"scanThisQRCodeOnYourPhone": "Scan this QR code on your phone",
"scanToConnect": "Scan to connect:",
"scanToOpen": "Scan to open:",
"selectAProviderAboveToConfigureRemoteAccess": "Select a provider above to configure remote access.",
"shortLivedToken": "Short-lived token",
"shortLivedTokenGenerated": "Short-lived token generated",
"shortLivedTTLMs": "Short-lived TTL (ms)",
"showURL": "Show URL",
"startFresh": "Start Fresh",
"starting": "Starting…",
"startTunnel": "Start Tunnel",
"stopping": "Stopping…",
"stopTunnel": "Stop Tunnel",
"tailnetURL": "Tailnet URL:",
"tailscale": "Tailscale",
"tailscaleFunnelWillExposeThisDashboardOnYour": "Tailscale Funnel will expose this dashboard on your tailnet's public ",
"tokenType": " Token type: ",
"tunnelDetected": " tunnel detected",
"tunnelName": "Tunnel name",
"tunnelRestarted": "Remote tunnel restarted",
"tunnelStarted": "Remote tunnel started",
"tunnelStopped": "Remote tunnel stopped",
"useExisting": "Use Existing"
"tunnelToken": "Tunnel token",
"uRLAndQRGenerationUseTheSelectedToken": " URL and QR generation use the selected token type. ",
"uRLNoHostnameOrPortConfigurationNeeded": " URL — no hostname or port configuration needed.",
"useExisting": "Use Existing",
"usingQuickTunnel": "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed."
},
"researchGlobal": {
"advancedExternalSearchProviders": "Advanced — external search providers",
"alwaysOn": "Always on",
"brave": "Brave",
"builtInUsesAgentWebTools": " Built-in (uses agent web tools) ",
"configureBraveTavilyAndGoogleAPIKeysIn": " Configure Brave, Tavily, and Google API keys in Authentication. ",
"customSearchEngineId": "custom-search-engine-id",
"defaultMaxConcurrentRuns": "Default Max Concurrent Runs",
"defaultMaxDurationMs": "Default Max Duration (ms)",
"defaultMaxSourcesPerRun": "Default Max Sources Per Run",
"enabledSources": "Enabled Sources",
"gitHub": " GitHub ",
"googleCustomSearch": "Google Custom Search",
"googleSearchCX": "Google Search CX",
"httpsSearxExampleCom": "https://searx.example.com",
"localDocs": " Local Docs ",
"maxSynthesisRounds": "Max Synthesis Rounds",
"missingCredentialsForTheSelectedResearchProvider": " Missing credentials for the selected research provider. ",
"openAuthentication": " Open Authentication ",
"openAuthenticationSettings": " Open Authentication Settings ",
"requestTimeoutMs": "Request Timeout (ms)",
"researchDefaults": "Research Defaults",
"searchesAndFetchesUseTheAgentsNativeWebSearch": " Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. ",
"searchProvider": "Search Provider",
"searXNG": "SearXNG",
"searXNGURL": "SearXNG URL",
"tavily": "Tavily",
"webSearch": " Web Search "
},
"researchProject": {
"alwaysOn": "Always on",
"enabledSources": "Enabled Sources",
"enableResearchInThisProject": " Enable research in this project ",
"github": "GitHub",
"llmSynthesis": "LLM Synthesis",
"localDocs": "Local Docs",
"maxConcurrentRuns": "Max Concurrent Runs",
"maxDurationMs": "Max Duration (ms)",
"maxSourcesPerRun": "Max Sources Per Run",
"pageFetch": "Page Fetch",
"projectResearchSettings": "Project Research Settings",
"requestTimeoutMs": "Request Timeout (ms)",
"webSearch": " Web Search ",
"webSearchIsAlwaysEnabledConfigureTheSearch": " Web search is always enabled. Configure the search provider under Research Defaults. "
},
"resolveAllLocal": "Resolve All: Keep Local",
"resolveAllRemote": "Resolve All: Keep Remote",
"resolvedSuccess": "Settings conflicts resolved successfully",
"resolveFailed": "Failed to resolve conflicts",
"resolving": "Resolving...",
"runtimesRuntimes": {
"hermesRuntime": "Hermes Runtime",
"openClawRuntime": "OpenClaw Runtime",
"paperclipRuntime": "Paperclip Runtime"
},
"scheduledEvals": {
"autoCreateTasks": "Auto-create tasks",
"disabled": "Disabled",
"enableScheduledEvalRunsForThisProject": " Enable scheduled eval runs for this project ",
"evaluatorModel": "Evaluator Model",
"evaluatorProvider": "Evaluator Provider",
"followUpPolicy": "Follow-up Policy",
"gpt5": "gpt-5",
"intervalMs": "Interval (ms)",
"leaveProviderAndModelBlankToInheritThe": " Leave provider and model blank to inherit the project validator lane model settings. ",
"openai": "openai",
"retentionDays": "Retention (days)",
"scheduledEvals": "Scheduled Evals",
"suggestOnly": "Suggest only"
},
"scheduling": {
"addIgnoredPath": " Add ignored path ",
"archiveAgentLog": "Archive Agent Log",
"archiveCompletedTasksAfterDays": "Archive Completed Tasks After (days)",
"backlogNoTaskAutoClaimIsExecutorOnly": "Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.",
"browse": " Browse ",
"closeParenPeriod": ").",
"compactModeKeepsArchiveSizeLowWhilePreserving": "Compact mode keeps archive size low while preserving recent agent activity for context.",
"compactSummaryAndRecentEntries": "Compact summary and recent entries",
"completedTasksOlderThanTheThresholdAreMoved": "Completed tasks older than the threshold are moved out of the active task database.",
"docs": "docs/",
"doNotArchiveAgentLogs": "Do not archive agent logs",
"enableAutomaticTaskArchiving": " Enable automatic task archiving ",
"enablePlanStalenessEnforcement": " Enable plan staleness enforcement ",
"escalateHighFanOutBlockersOnlyAfterThey": "Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.",
"fullAgentLog": "Full agent log",
"globalMaxConcurrent": "Global Max Concurrent",
"heartbeatScopeDiscipline": "Heartbeat Scope Discipline",
"ignoredOverlapPaths": "Ignored overlap paths",
"letEngineerAgentsAutoClaimBacklogTasks": " Let engineer agents auto-claim backlog tasks ",
"lite": "Lite",
"maxConcurrentTasks": "Max Concurrent Tasks",
"maximumAgeInHoursBeforeAPlanIs": "Maximum age in hours before a plan is considered stale. Default: 6 hours.",
"maximumConcurrentAgentsAcrossAllProjects": "Maximum concurrent agents across all projects",
"maximumConcurrentPlanningAgents": "Maximum concurrent planning agents",
"maximumStuckDetectorRetriesBeforeATaskIs": "Maximum stuck-detector retries before a task is marked failed. Default: 6.",
"maxStuckRetries": "Max Stuck Retries",
"maxTriageConcurrent": "Max Triage Concurrent",
"numberOfDaysATaskCanStayIn": "Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).",
"off": "Off",
"optionalFileOrDirectoryPathsToIgnoreWhen": " Optional file or directory paths to ignore when overlap serialization is enabled. Paths are project-relative (for example ",
"or": " or ",
"pollIntervalMs": "Poll Interval (ms)",
"preserveStepProgressOnStuckTaskRequeue": " Preserve step progress on stuck-task requeue ",
"remove": " Remove ",
"scheduling": "Scheduling",
"selectCurrentDir": "Select current directory",
"selectIgnoredOverlapPath": "Select ignored overlap path"
"selectIgnoredOverlapPath": "Select ignored overlap path",
"serializeTasksWithOverlappingFiles": " Serialize tasks with overlapping files ",
"staleHighFanOutEscalationHours": "Stale High Fan-out Escalation (hours)",
"staleSpecThresholdHours": "Stale Spec Threshold (hours)",
"stepExecution": "Step Execution",
"strictCoordinationFocusedHigherPerTickTokensLite": "Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.",
"strictDefault": "Strict (default)",
"stuckTaskTimeoutMinutes": "Stuck Task Timeout (minutes)",
"timeoutInMinutesForDetectingStuckTasksWhen": "Timeout in minutes for detecting stuck tasks. When a task&apos;s agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.",
"whenEnabledTasksThatModifyTheSameFiles": "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts",
"whenEnabledTasksWithStalePlansPROMPTMd": "When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning",
"whenTheStuckDetectorKillsAndReQueues": "When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled."
},
"scope": {
"globalBanner": "These settings are shared across all your Fusion projects.",
@@ -5649,11 +6283,59 @@
},
"title": "Settings",
"worktrees": {
"allowSilentSiblingBranchRenameDuringExecutorConflicts": " Allow silent sibling branch rename during executor conflicts ",
"alsoRebaseOntoLocalDefaultBranchHEAD": " Also rebase onto local default-branch HEAD ",
"and": " and ",
"andCanHidePriorCommitsFromTheDefault": " and can hide prior commits from the default recovery flow. ",
"autoDetectFusionBinWorktrunkOrPATH": "auto-detect (~/.fusion/bin/worktrunk or $PATH)",
"awaitingApproval": "Awaiting approval — open Approvals to continue.",
"branchCollisionSilentlyForksWorkOntoSiblingBranches": " branch collision silently forks work onto sibling branches like ",
"browse": " Browse ",
"browseWorktreesDirectory": "Browse worktrees directory",
"closeParenPeriod": ").",
"defaultsTo": ". Defaults to ",
"defaultsToWorktreesLeaveEmptyUnlessOverriding": "Defaults to .worktrees — leave empty unless overriding",
"disabledByDefaultOptInWhenEnabledFusion": " Disabled by default (opt-in). When enabled, Fusion shells out to ",
"discouragedThisRestoresTheLegacyBehaviorWhereA": " Discouraged. This restores the legacy behavior where a live ",
"enableWorktrunkAndRequestApprovalToInstallThe": "Enable worktrunk and request approval to install the pinned release.",
"enableWorktrunkIntegration": " Enable worktrunk integration ",
"failAndPauseTheTaskDefault": "Fail and pause the task (default)",
"fallBackToFusionsNativeWorktreeBackend": "Fall back to Fusion's native worktree backend",
"forWorktreeCreateSyncPruneAndRemoveOperations": " for worktree create, sync, prune, and remove operations and follows worktrunk&apos;s directory layout. ",
"inAdditionToTheRemoteRebaseAboveAlso": " In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. ",
"installedAt": " installed at ",
"installTheWorktrunkBinaryBelowToEnableThis": "Install the worktrunk binary below to enable this integration.",
"installWorktrunk": "Install worktrunk binary",
"keepsProgressMovingBySwitchingToFusionApos": " keeps progress moving by switching to Fusion&apos;s built-in worktree backend. ",
"limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks",
"maxWorktrees": "Max Worktrees",
"offByDefaultOptInWhenEnabledCompleted": "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup",
"openApprovals": "Open Approvals",
"optionalLeaveBlankToAutoResolveFusionWill": "Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.",
"optionalSupports": " Optional. Supports ",
"pnpmInstallFrozenLockfile": "pnpm install --frozen-lockfile",
"randomNamesEGSwiftFalcon": "Random names (e.g., swift-falcon)",
"rebaseFromRemoteBeforeMerge": " Rebase from remote before merge ",
"rebaseRemote": "Rebase Remote",
"recycleWorktrees": " Recycle worktrees ",
"selectWorktreesDir": "Select worktrees directory",
"tryAgain": "Try again"
"shellCommandToRunInEachNewWorktree": "Shell command to run in each new worktree after creation",
"stopsOnWorktrunkErrorsForExplicitOperatorRecovery": " stops on worktrunk errors for explicit operator recovery; ",
"taskIDEGFN042": "Task ID (e.g., FN-042)",
"taskTitleEGFixLoginBug": "Task title (e.g., fix-login-bug)",
"tryAgain": "Try again",
"useGitDefault": "Use git default",
"whenEnabledTheMergerFetchesFromTheConfigured": "When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging — catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline.",
"whenUnsetOnlyAffectsNewlyCreatedWorktrees": " when unset. Only affects newly-created worktrees. ",
"whichRemoteToFetchForThePreMerge": " Which remote to fetch for the pre-merge rebase. \"Use git default\" falls back to the remote configured for the default branch (typically ",
"worktreeInitCommand": "Worktree Init Command",
"worktreeNamingStyle": "Worktree Naming Style",
"worktrees": "Worktrees",
"worktreesDirectory": "Worktrees Directory",
"worktrunk": " worktrunk ",
"worktrunkBinaryPath": "Worktrunk binary path",
"worktrunkFailureBehavior": "Worktrunk failure behavior",
"worktrunkIntegration": "Worktrunk integration"
}
},
"setup": {

View File

@@ -142,6 +142,14 @@
"notConfigured": "Not configured"
}
},
"settings": {
"nodeRouting": {
"statusConnecting": "Connecting",
"statusError": "Error",
"statusOffline": "Offline",
"statusOnline": "Online"
}
},
"setup": {
"apiKeyFormatError": "{{providerName}} keys should follow this format: {{hint}} (e.g. {{example}})",
"apiKeyLabel": {

View File

@@ -872,6 +872,7 @@
"resetDayMonthly": "Día del mes (1–28)",
"resetDayWeekly": "Día de la semana (0=Dom)",
"resetting": "Restableciendo...",
"resizeSidebar": "",
"result": "Resultado",
"resultCreated_one": "",
"resultCreated_other": "",
@@ -1907,7 +1908,6 @@
"title": "Crear sala"
},
"dashboard": {
"brandName": "Fusion",
"initializingDashboard": "Inicializando panel...",
"loadingMessage": "Cargando panel de Fusion",
"loadingProgress": "Progreso de carga del panel",
@@ -5458,10 +5458,18 @@
"cancel": "Cancelar",
"save": "Guardar"
},
"agentPermissions": {
"agentPermissions": "",
"agentProvisioningApprovals": "",
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
},
"appearance": {
"hideAISessionNotificationBanners": "",
"language": "Idioma",
"languageAuto": "Automático",
"languageAutoHint": "Seguir el idioma del navegador",
"suppressTheLdquoNeedsYourInputRdquoBanner": "",
"title": "Apariencia"
},
"auth": {
@@ -5501,10 +5509,51 @@
"title": "Autenticación",
"waitingForLogin": "Esperando inicio de sesión…"
},
"authentication": {
"enterAPIKey": "",
"key": ""
},
"backups": {
"02": "",
"03": "",
"agentsOnlyFusionAgentMemory": "",
"allProjectAgents": "",
"and": "",
"backupCreated": "Copia de seguridad creada exitosamente",
"backupDirectory": "",
"backupNow": "Hacer copia ahora",
"creating": "Creando…"
"backups": "",
"backupS": "",
"backupScheduleCron": "",
"creating": "Creando…",
"cronExpressionForBackupTimingDefault02": "",
"cronExpressionForMemoryBackupTimingDefault0": "",
"currentBackups": "",
"databaseBackups": "",
"directoryForBackupFilesRelativeToProjectRoot": "",
"directoryForMemoryBackupsRelativeToProjectRoot": "",
"enableAutomaticDatabaseBackups": "",
"enableAutomaticMemoryBackups": "",
"fusionBackups": "",
"fusionBackupsMemory": "",
"invalidCronExpressionFormat": "",
"loadingBackupInfo": "",
"memoryBackupDirectory": "",
"memoryBackups": "",
"memoryBackupScheduleCron": "",
"memoryBackupScope": "",
"memoryRetentionCount": "",
"more": "",
"mustBeBetween1And100": "",
"numberOfBackupFilesToKeepOldestAre": "",
"numberOfMemoryBackupsToKeepOldestAre": "",
"pathCannotContainParentDirectoryTraversal": "",
"projectOnlyFusionMemory": "",
"retentionCount": "",
"totalSize": "",
"view": "",
"whenEnabledProjectAndAgentMemoryFilesAre": "",
"whenEnabledTheDatabaseIsBackedUpAutomatically": ""
},
"clearToDefault": "",
"cliAgents": {
@@ -5531,14 +5580,80 @@
"saveFailed": ""
},
"closeModal": "Cerrar modal de conflicto",
"commands": {
"buildCommand": "",
"commands": "",
"commandUsedToBuildTheProjectInjectedInto": "",
"commandUsedToRunTestsInjectedIntoGenerated": "",
"eGPnpmBuild": "",
"eGPnpmTest": "",
"testCommand": ""
},
"conflictModalTitle": "Resolver conflictos de configuración",
"experimental": {
"experimentalFeatures": "",
"experimentalFeaturesAreEarlyCapabilitiesThatAreNot": "",
"featureFlags": ""
},
"footer": {
"help": "Ayuda",
"version": "Versión {{version}}"
},
"general": {
"25": "",
"200": "",
"3000": "",
"14Days": "",
"30Days": "",
"60Days": "",
"7Days": "",
"90Days": "",
"agentsToRunEachTaskWhenDisabledOnly": "",
"autoCleanupOldChats": "",
"autoPruneOldMail": "",
"bannerFiresWhenTodoCountIsStrictlyGreater": "",
"builtInWorkflows": "",
"capacityRiskBanner": "",
"chatHistory": "",
"chatRooms": "",
"completionDocumentationAutomation": "",
"controlsHowFutureTaskSpecsHandleReleaseNote": "",
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": "",
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "",
"defaultTrackingModeForNewTasks": "",
"deleteChatSessionsAndRoomsThatHaveBeen": "",
"deleteInboxOutboxMessagesOlderThanThisMany": "",
"disabledBuiltInWorkflowsAreHiddenFromWorkflow": "",
"enableSummarizationInProjectModelsToConfigureThatModel": "",
"fN": "",
"general": "",
"gitHubTracking": "",
"hardCapOnTheSynthesizedEarlierRoomContext": "",
"learnMore": "Más información",
"loweringThisWindowMeansReliabilityMetricsChartsAnd": "",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "",
"numberOfMostRecentChatRoomMessagesKept": "",
"off": "",
"offDefault": "",
"onForNewTasks": "",
"operationalLogRetention": "",
"ownerRepo": "",
"prefixForNewTaskIDsEGKB": "",
"prefixMustBe110UppercaseLetters": "",
"projectDefaultTrackingRepo": "",
"recentVerbatimRoomMessages": "",
"requireChangelogUpdateExistingChangelog": "",
"requireChangesetChangesetMd": "",
"roomCompactionFetchLimit": "",
"roomSummaryMaxCharacters": "",
"searchTheTrackingRepoForLikelyDuplicatesBefore": "",
"settingsSaved": "Configuración guardada",
"showCapacityRiskBanner": "",
"showQuickChatButton": "",
"showTheFloatingChatButtonInTheDashboard": "",
"taskPrefix": "",
"todoThreshold": "",
"trackingIssuesUseThisTaskAposSTitle": "",
"updateAvailablePrefix": "v{{version}} disponible",
"updateCheckFailed": "",
"updateFailed": "",
@@ -5547,7 +5662,88 @@
"updateSuccess": "",
"updateSuccessToast": "",
"updating": "",
"upToDate": "Estás actualizado ✓"
"upperBoundOnMessagesFetchedFromTheRoom": "",
"upToDate": "Estás actualizado ✓",
"useEphemeralTaskWorkerAgents": "",
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
"autoReloadDashboardOnVersionChange": "",
"checkForThe": "",
"checkForUpdatesAutomatically": "",
"cLIBinaryOnPATH": "",
"cLIBySpawning": "",
"controlsHowOftenTheDashboardReFetchesThe": "",
"dailyRecommended": "",
"disableThisIfYourLocalDevProcessIs": "",
"frequency": "",
"general": "",
"globalDefaultTrackingRepo": "",
"leaveBothThinkingTogglesOffToKeepThe": "",
"manualOnlyNeverAutoCheck": "",
"onStartupOncePerServerLaunch": "",
"ownerRepo": "",
"projectsInheritThisValueWhenTheyDoNot": "",
"rowsAndDoesNotAffectAssistantTextOr": "",
"saveAIThinkingForEphemeralTaskWorkerAgents": "",
"saveAIThinkingForPermanentAgents": "",
"saveAIThinkingLogs": "",
"saveToolOutputInAgentLogs": "",
"updates": "",
"weekly": "",
"whenDisabledToolRowsAreStillLoggedBut": "",
"whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "",
"whenEnabledFusionChecksNpmForNewVersions": "",
"whenEnabledTheDashboardProbesForAGlobally": ""
},
"globalModels": {
"allow": "",
"commaSeparatedValuesSentToOpenRouterModelSync": "",
"controlsHowMuchReasoningEffortTheAIModel": "",
"default": "",
"default2": "",
"defaultAIModelUsedForTaskExecutionWhen": "",
"defaultModel": "",
"deny": "",
"fallbackModel": "",
"flowAndPublishesThemUnderTheOpencodeGo": "",
"fusion": "",
"globalBaselineModelsForEachAIRoleProject": "",
"httpsRunfusionAi": "",
"latency": "",
"leaveEmptyToOmitThisHeaderDefaultFusion": "",
"leaveEmptyToOmitThisHeaderDefaultHttps": "",
"modelLanes": "",
"noFallback": "",
"openaiAnthropic": "",
"openRouterAdvanced": "",
"openRouterAllowFallbacks": "",
"openRouterHTTPReferer": "",
"openRouterOutputModalitiesFilter": "",
"openRouterRoutingIgnore": "",
"openRouterRoutingOnly": "",
"openRouterRoutingOrder": "",
"openRouterRoutingSort": "",
"openRouterSupportedParametersFilter": "",
"openRouterXTitle": "",
"price": "",
"providerName": "",
"requireParameters": "",
"startupModelSync": "",
"syncOpencodeGoModelListAtStartup": "",
"syncOpenRouterModelListAtStartup": "",
"text": "",
"thinkingEffort": "",
"throughput": "",
"toolsStructuredOutputs": "",
"usedAutomaticallyIfThePrimaryDefaultModelHits": "",
"useDefault": "",
"whenEnabledStartupFetchesTheLatestAvailableModels": "",
"whenEnabledStartupRefreshesModelsThroughTheLocal": ""
},
"header": {
"discord": "Discord"
@@ -5567,17 +5763,179 @@
"keepRemote": "Mantener remoto",
"loading": "Cargando…",
"memory": {
"03": "",
"agentsGetMemorySearchMemoryGetAndMemory": "",
"agentsSearchWithQmdFirstFallBackTo": "",
"automaticallyCompactMemoryWhenItExceedsTheThreshold": "",
"autoSummarizeMemory": "",
"bytesUpdated": "",
"cannotSave": "",
"checkingMemoryWriteAccess": "",
"compacting": "Compactando…",
"compactionThresholdChars": "",
"compactSelectedFile": "Compactar archivo seleccionado",
"cronExpressionForAutoSummarizeScheduleDefaultDaily": "",
"cronExpressionForDreamProcessing": "",
"dreamCompleted": "Procesamiento de sueños completado",
"dreaming": "",
"dreamNow": "Soñar ahora",
"dreamSchedule": "",
"enableMemoryTools": "",
"failedToLoadBackendStatus": "",
"for": "",
"installing": "Instalando…",
"installQmd": "Instalar qmd",
"loadingMemory": "",
"manuallyTriggerDreamProcessingNow": "",
"memory": "",
"memoryCompacted": "Archivo de memoria compactado",
"memoryFile": "",
"memoryIsConfiguredWithAReadOnlyBackend": "",
"memoryIsCurrentlyDisabledYouCanViewThe": "",
"memoryLivesIn": "",
"memorySaved": "Memoria guardada",
"memoryWillBeCompactedWhenItExceedsThis": "",
"noMatchingMemoryFound": "",
"processDreamsFromDailyMemory": "",
"qmd": "",
"qmdIsNotInstalledSearchWillUseLocal": "",
"result": "",
"runsTheSameQmdBackedMemorySearchPath": "",
"saveMemory": "Guardar memoria",
"scheduleCron": "",
"searchMemoryWithQmd": "",
"testing": "Probando…",
"testRetrieval": "Probar recuperación"
"testRetrieval": "Probar recuperación",
"turnsDailyNotesIntoDREAMSMdAndPromotes": ""
},
"merge": {
"abort": "",
"abortOneAIAttemptRequireManualResolutionIf": "",
"addFusionAsCoAuthorOnCommits": "",
"afterFusionAdvancesTheIntegrationBranchRefThe": "",
"aICorrectiveRoundsBeforeLandingTheBestResult": "",
"aIMerge": "",
"aIMergeDefaultAIMergesInAClean": "",
"aIModeMergesTheTaskBranchIntoAn": "",
"aIOnly": "",
"aIOnlyAIAutoResolveAIRetryNever": "",
"allowAIMergeToSyncADirtyChecked": "",
"alwaysPreserveDirectMergeCommitHistory": "",
"alwaysSquashDirectMerges": "",
"and": "",
"and2": "",
"areListedFirstOrChoose": "",
"autoCompletionMode": "",
"autoDetect": "",
"autoDetectOriginHEADMain": "",
"autoKeepsTodayAposSSquashBehaviorFor": "",
"autoMergeCompletedTasks": "",
"autoMergeConflictRetries": "",
"autoMergeRunsInTheTaskWorktreeBy": "",
"autoMergeWillRunRebaseConflictResolutionAnd": "",
"autoResolveConflictsInLockFilesAndGenerated": "",
"autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive": "",
"autoSyncProjectCheckoutAfterMerge": "",
"block": "",
"blockStrict": "",
"both": "",
"branchName": "",
"checkingForLegacyAutoMergeStamps": "",
"coAuthorEmail": "",
"coAuthorName": "",
"conflictFallbackStrategy": "",
"controlsThePostMergeAuditGate": "",
"controlsWhatHappensAfterATaskReachesIn": "",
"createMonitorAndMergeAGitHubPullRequest": "",
"custom": "",
"dangerousCompatibilityEscapeHatchLeaveOffUnlessYou": "",
"defaultLogsFindingsButAutoCompletesTheMerge": "",
"deterministicLegacyRebaseConflictStrategyAuditPipeline": "",
"directMergeCommitRouting": "",
"directMergeIntoTheCurrentBranch": "",
"emailUsedInThe": "",
"enableTestMode": "",
"fastForwardOfLocalMainFrom": "",
"fastForwardOnlySkipDirtyWorktrees": "",
"finalFallback": "",
"findsInReviewTasksWhoseAutoMergeValue": "",
"flipOverlappingFilesToPreferTheTaskBranch": "",
"forcesAllAILanesToUseTheDeterministic": "",
"fusion": "",
"gitHubAuthentication": "",
"gitHubAuthMode": "",
"gitHubCLIGhAuth": "",
"gitHubPersonalAccessToken": "",
"gitRemoteToPushToEGOrigin": "",
"ignoreOverlapDetectionPreserveLegacyBehavior": "",
"includeTaskIDInCommitScope": "",
"insteadOf": "",
"integrationBranch": "",
"integrationWorktree": "",
"inYourProjectRootWillShowTheNew": "",
"isTheLegacyBehavior": "",
"isTheStricterOptInModeThatRefuses": "",
"legacy": "",
"legacy2": "",
"legacyAutoMergeStamp": "",
"legacyAutoMergeStampCleanup": "",
"legacyIntegrationBranchMode": "",
"maxAIReviewPasses": "",
"merge": "",
"moreDetails": "",
"nameUsedInThe": "",
"noLegacyAutoMergeStampsToCleanUp": "",
"noreplyRunfusionAi": "",
"off": "",
"offLeaveTheProjectRootStaleLegacyBehavior": "",
"offSkipAudit": "",
"optionsStartWithABestEffort": "",
"origin": "",
"personalAccessToken": "",
"pickALocalBranchFromTheDropdownCommon": "",
"positiveIntegerRetryCapForAutoMergeConflict": "",
"postMergeAuditMode": "",
"pushRemote": "",
"pushToRemoteAfterMerge": "",
"readyToCleanUp": "",
"retriesTheAIAgentRatherThanAutoPicking": "",
"reuseTaskWorktreeDefault": "",
"skipsTheAuditEntirelySwitchingToOffIs": "",
"smart": "",
"smartConflictResolution": "",
"smartPreferMain": "",
"smartPreferMainOnFallbackFetchFfOrigin": "",
"smartPreferMainOverlapGuard": "",
"smartPreferTask": "",
"smartPreferTaskOnFallbackFetchFfOrigin": "",
"snapsCleanlyWhenTheWorktreeHasNoEdits": "",
"snapshotsRealLocalEditsAsAPatchAgainst": "",
"soAFreshlyPushedSiblingCommitDoesntGet": "",
"soMainWinsProtectsJustMergedSiblingWork": "",
"soTheTaskBranchWinsFastButCan": "",
"stashFastForwardDefaultPreserveLocalEdits": "",
"stopsAfterTheFirstAIAttemptAndWaits": "",
"symbolicRefFallback": "",
"theCanonicalBranchFusionMergesTasksIntoAnd": "",
"theLegacyMergeSettingsBelowDoNotApply": "",
"toResolveViaTheStandardCascade": "",
"toTypeABranchThatDoesnAposT": "",
"trailer": "",
"trailerCreditingFusionRecognizedByGitHubForShared": "",
"useDropdown": "",
"useProjectRootLegacy": "",
"uses": "",
"valuesFromOlderSettingsAreMigratedAutomatically": "",
"warn": "",
"warnDefaultLogFindingsContinue": "",
"warnOnlyKeepLegacyMainWinsFallback": "",
"whenDisabledMergeCommitMessagesOmitTheTask": "",
"whenEnabledCommitsMadeByFusionKeepYour": "",
"whenEnabledLockFilesPackageLockJsonPnpm": "",
"whenEnabledLockFilesPackageLockJsonPnpm2": "",
"whenEnabledTasksThatPassReviewAreAutomatically": "",
"whenEnabledTheMergedResultIsAutomaticallyPushed": "",
"whenUsingSmartPreferMainAutomaticallyPreferThe": ""
},
"mergeManually": "Fusionar manualmente",
"mobileNav": {
@@ -5610,38 +5968,314 @@
"project": "Específico de este proyecto"
}
},
"nodeRouting": {
"blockExecution": "",
"configureHowTasksAreRoutedToExecutionNodes": "",
"defaultExecutionNode": "",
"fallBackToLocal": "",
"localExecutionNoDefaultNode": "",
"nodeRouting": "",
"selectedNode": "",
"theseSettingsApplyAtTheProjectLevel": "",
"unavailableNodePolicy": "",
"usedWhenATaskHasNoNodeOverride": ""
},
"nodeSync": {
"alwaysAsk": "",
"automaticallySynchronizeSettingsBetweenThisNodeAndConnected": "",
"conflictResolution": "",
"enableAutomaticSettingsSync": "",
"every15Minutes": "",
"every1Hour": "",
"every30Minutes": "",
"every5Minutes": "",
"includeAPIKeysAndOAuthTokensInSync": "",
"keepLocal": "",
"keepRemote": "",
"lastWriteWins": "",
"nodeSync": "",
"syncInterval": "",
"syncModelAuthCredentials": "",
"workflowSettingsNotSynced": ""
},
"notifications": {
"accessTokenOptional": "",
"advanced": "",
"allFailuresLegacy": "",
"baseURLForDeepLinksInNotificationsWhen": "",
"customNtfyServerURLOptional": "",
"dashboardHostname": "",
"discord": "",
"enable": "",
"failureNotificationDelayMs": "",
"failureNotificationMode": "",
"format": "",
"general": "",
"generic": "",
"howLongAFailureMustPersistBeforeA": "",
"httpLocalhost3000": "",
"httpsHooksExampleCom": "",
"httpsNtfySh": "",
"learnMoreAboutNtfySh": "",
"leaveBlankToKeepTheDefaultServerHttps": "",
"leaveBlankToPublishWithoutAuthenticationWhenSet": "",
"messageInbox": "",
"mustBeAValidURLStartingWithHttp": "",
"myTopicName": "",
"notifications": "",
"notifyOnEvents": "",
"ntfy": "",
"ntfyTopic": "",
"roomReply": "",
"sending": "Enviando…",
"slack": "",
"stickyFailuresOnlyDefault": "",
"stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits": "",
"terminalFailuresOnlySuppressAutoRetried": "",
"testMessageInbox": "Probar bandeja de mensajes",
"testNotification": "Notificación de prueba",
"testRoomReply": "Probar respuesta en sala"
"testRoomReply": "Probar respuesta en sala",
"tk": "",
"topicMustBe164AlphanumericHyphenOr": "",
"webhook": "",
"webhookNotifications": "",
"webhookURL": "",
"yourNtfyShTopicName164Alphanumeric": ""
},
"plugins": {
"fusionPlugins": "",
"piExtensions": "",
"pluginManagerType": "",
"plugins": ""
},
"projectModels": {
"addPreset": "",
"advancedWorkflowPolicy": "",
"aIMergeCommitSummaries": "",
"aITitleAndGitCommitMessageSummarization": "",
"automaticallyCompactContextWhenApproachingThisTokenCount": "",
"autoSelectPresetBasedOnTaskSize": "",
"autoSummarizeLongDescriptionsAsTitles": "",
"configuredPresets": "",
"configuresTheModelUsedForTwoShortSummary": "",
"defaultWorkflowModelLaneActions": "",
"defaultWorkflowModelLanes": "",
"delete": "",
"edit": "",
"executorModel": "",
"fallsBackTo": "",
"loadingAvailableModels": "",
"loadingWorkflowModelLanes": "",
"modelLanes": "",
"modelPresets": "",
"name": "",
"noCap": "",
"noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow": "",
"noModelsAvailableConfigureAuthenticationFirst": "",
"noModelsAvailableConfigureAuthenticationFirst2": "",
"noPreset": "",
"noPresetsConfiguredYet": "",
"openAProjectToEditWorkflowModelLanes": "",
"overrideGlobalModelSettingsAtTheProjectLevel": "",
"presetEditor": "",
"reset": "",
"resetToDefaultNoCap": "",
"resetToInheritFromGlobal": "",
"resetToInheritFromWorkflow": "",
"reviewerModel": "",
"theseProjectOverridesApplyToTheActiveDefault": "",
"tokenCap": "",
"useDefault": "",
"useWorkflowDefault": "",
"whenEnabledMergeCommitMessagesIncludeAnAI": "",
"whenEnabledTasksCreatedWithoutATitleBut": ""
},
"remote": {
"acceptRoutes": "",
"advancedNamedTunnel": "",
"advancedSettings": "",
"authenticatedURL": "",
"authLinks": "",
"authLinkTokenType": "",
"automaticallyRestoreTunnelOnStartupIfItWas": "",
"cloudflare": "",
"cloudflaredInstalled": "cloudflared instalado exitosamente",
"cloudflaredIsInstalled": "",
"cloudflaredIsNotInstalled": "",
"cloudflaredMustBeInstalledToStartTheTunnel": "",
"enableShortLivedTokens": "",
"expiresAt": "",
"external": "",
"externalTunnelQRCode": "",
"generateQR": "",
"generateShortLivedToken": "",
"httpsYourDomainExample": "",
"ifHomebrewIsUnavailable": "",
"ingressURL": "",
"installCloudflared": "",
"installing": "",
"lastShortLivedTokenExpiresAt": "",
"manualInstall": "",
"ms": "",
"namedTunnelModeEnabled": "",
"noExpiry": "",
"persistentToken": "",
"persistentTokenRegenerated": "Token persistente regenerado",
"qRSVGMarkup": "",
"regeneratePersistentToken": "",
"rememberLastRunningState": "",
"remoteAccess": "",
"remoteAccessCode": "",
"remoteAccessQRCode": "",
"remoteProvider": "",
"restarting": "Reiniciando…",
"scanThisQRCodeOnYourPhone": "",
"scanToConnect": "",
"scanToOpen": "",
"selectAProviderAboveToConfigureRemoteAccess": "",
"shortLivedToken": "",
"shortLivedTokenGenerated": "Token de corta duración generado",
"shortLivedTTLMs": "",
"showURL": "",
"startFresh": "Iniciar nuevo",
"starting": "Iniciando…",
"startTunnel": "Iniciar túnel",
"stopping": "Deteniendo…",
"stopTunnel": "Detener túnel",
"tailnetURL": "",
"tailscale": "",
"tailscaleFunnelWillExposeThisDashboardOnYour": "",
"tokenType": "",
"tunnelDetected": "",
"tunnelName": "",
"tunnelRestarted": "Túnel remoto reiniciado",
"tunnelStarted": "Túnel remoto iniciado",
"tunnelStopped": "Túnel remoto detenido",
"useExisting": "Usar existente"
"tunnelToken": "",
"uRLAndQRGenerationUseTheSelectedToken": "",
"uRLNoHostnameOrPortConfigurationNeeded": "",
"useExisting": "Usar existente",
"usingQuickTunnel": ""
},
"researchGlobal": {
"advancedExternalSearchProviders": "",
"alwaysOn": "",
"brave": "",
"builtInUsesAgentWebTools": "",
"configureBraveTavilyAndGoogleAPIKeysIn": "",
"customSearchEngineId": "",
"defaultMaxConcurrentRuns": "",
"defaultMaxDurationMs": "",
"defaultMaxSourcesPerRun": "",
"enabledSources": "",
"gitHub": "",
"googleCustomSearch": "",
"googleSearchCX": "",
"httpsSearxExampleCom": "",
"localDocs": "",
"maxSynthesisRounds": "",
"missingCredentialsForTheSelectedResearchProvider": "",
"openAuthentication": "",
"openAuthenticationSettings": "",
"requestTimeoutMs": "",
"researchDefaults": "",
"searchesAndFetchesUseTheAgentsNativeWebSearch": "",
"searchProvider": "",
"searXNG": "",
"searXNGURL": "",
"tavily": "",
"webSearch": ""
},
"researchProject": {
"alwaysOn": "",
"enabledSources": "",
"enableResearchInThisProject": "",
"github": "",
"llmSynthesis": "",
"localDocs": "",
"maxConcurrentRuns": "",
"maxDurationMs": "",
"maxSourcesPerRun": "",
"pageFetch": "",
"projectResearchSettings": "",
"requestTimeoutMs": "",
"webSearch": "",
"webSearchIsAlwaysEnabledConfigureTheSearch": ""
},
"resolveAllLocal": "Resolver todo: Mantener local",
"resolveAllRemote": "Resolver todo: Mantener remoto",
"resolvedSuccess": "Conflictos de configuración resueltos exitosamente",
"resolveFailed": "No se pudieron resolver los conflictos",
"resolving": "Resolviendo...",
"runtimesRuntimes": {
"hermesRuntime": "",
"openClawRuntime": "",
"paperclipRuntime": ""
},
"scheduledEvals": {
"autoCreateTasks": "",
"disabled": "",
"enableScheduledEvalRunsForThisProject": "",
"evaluatorModel": "",
"evaluatorProvider": "",
"followUpPolicy": "",
"gpt5": "",
"intervalMs": "",
"leaveProviderAndModelBlankToInheritThe": "",
"openai": "",
"retentionDays": "",
"scheduledEvals": "",
"suggestOnly": ""
},
"scheduling": {
"addIgnoredPath": "",
"archiveAgentLog": "",
"archiveCompletedTasksAfterDays": "",
"backlogNoTaskAutoClaimIsExecutorOnly": "",
"browse": "",
"closeParenPeriod": "",
"compactModeKeepsArchiveSizeLowWhilePreserving": "",
"compactSummaryAndRecentEntries": "",
"completedTasksOlderThanTheThresholdAreMoved": "",
"docs": "",
"doNotArchiveAgentLogs": "",
"enableAutomaticTaskArchiving": "",
"enablePlanStalenessEnforcement": "",
"escalateHighFanOutBlockersOnlyAfterThey": "",
"fullAgentLog": "",
"globalMaxConcurrent": "",
"heartbeatScopeDiscipline": "",
"ignoredOverlapPaths": "",
"letEngineerAgentsAutoClaimBacklogTasks": "",
"lite": "",
"maxConcurrentTasks": "",
"maximumAgeInHoursBeforeAPlanIs": "",
"maximumConcurrentAgentsAcrossAllProjects": "",
"maximumConcurrentPlanningAgents": "",
"maximumStuckDetectorRetriesBeforeATaskIs": "",
"maxStuckRetries": "",
"maxTriageConcurrent": "",
"numberOfDaysATaskCanStayIn": "",
"off": "",
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
"or": "",
"pollIntervalMs": "",
"preserveStepProgressOnStuckTaskRequeue": "",
"remove": "",
"scheduling": "",
"selectCurrentDir": "Seleccionar directorio actual",
"selectIgnoredOverlapPath": "Seleccionar ruta de solapamiento ignorada"
"selectIgnoredOverlapPath": "Seleccionar ruta de solapamiento ignorada",
"serializeTasksWithOverlappingFiles": "",
"staleHighFanOutEscalationHours": "",
"staleSpecThresholdHours": "",
"stepExecution": "",
"strictCoordinationFocusedHigherPerTickTokensLite": "",
"strictDefault": "",
"stuckTaskTimeoutMinutes": "",
"timeoutInMinutesForDetectingStuckTasksWhen": "",
"whenEnabledTasksThatModifyTheSameFiles": "",
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": ""
},
"scope": {
"globalBanner": "Estos ajustes se comparten entre todos tus proyectos de Fusion.",
@@ -5649,11 +6283,59 @@
},
"title": "Configuración",
"worktrees": {
"allowSilentSiblingBranchRenameDuringExecutorConflicts": "",
"alsoRebaseOntoLocalDefaultBranchHEAD": "",
"and": "",
"andCanHidePriorCommitsFromTheDefault": "",
"autoDetectFusionBinWorktrunkOrPATH": "",
"awaitingApproval": "Esperando aprobación — abre Aprobaciones para continuar.",
"branchCollisionSilentlyForksWorkOntoSiblingBranches": "",
"browse": "",
"browseWorktreesDirectory": "",
"closeParenPeriod": "",
"defaultsTo": "",
"defaultsToWorktreesLeaveEmptyUnlessOverriding": "",
"disabledByDefaultOptInWhenEnabledFusion": "",
"discouragedThisRestoresTheLegacyBehaviorWhereA": "",
"enableWorktrunkAndRequestApprovalToInstallThe": "",
"enableWorktrunkIntegration": "",
"failAndPauseTheTaskDefault": "",
"fallBackToFusionsNativeWorktreeBackend": "",
"forWorktreeCreateSyncPruneAndRemoveOperations": "",
"inAdditionToTheRemoteRebaseAboveAlso": "",
"installedAt": "",
"installTheWorktrunkBinaryBelowToEnableThis": "",
"installWorktrunk": "Instalar binario de worktrunk",
"keepsProgressMovingBySwitchingToFusionApos": "",
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
"maxWorktrees": "",
"offByDefaultOptInWhenEnabledCompleted": "",
"openApprovals": "Abrir aprobaciones",
"optionalLeaveBlankToAutoResolveFusionWill": "",
"optionalSupports": "",
"pnpmInstallFrozenLockfile": "",
"randomNamesEGSwiftFalcon": "",
"rebaseFromRemoteBeforeMerge": "",
"rebaseRemote": "",
"recycleWorktrees": "",
"selectWorktreesDir": "Seleccionar directorio de worktrees",
"tryAgain": "Intentar de nuevo"
"shellCommandToRunInEachNewWorktree": "",
"stopsOnWorktrunkErrorsForExplicitOperatorRecovery": "",
"taskIDEGFN042": "",
"taskTitleEGFixLoginBug": "",
"tryAgain": "Intentar de nuevo",
"useGitDefault": "",
"whenEnabledTheMergerFetchesFromTheConfigured": "",
"whenUnsetOnlyAffectsNewlyCreatedWorktrees": "",
"whichRemoteToFetchForThePreMerge": "",
"worktreeInitCommand": "",
"worktreeNamingStyle": "",
"worktrees": "",
"worktreesDirectory": "",
"worktrunk": "",
"worktrunkBinaryPath": "",
"worktrunkFailureBehavior": "",
"worktrunkIntegration": ""
}
},
"setup": {

View File

@@ -142,6 +142,14 @@
"notConfigured": ""
}
},
"settings": {
"nodeRouting": {
"statusConnecting": "",
"statusError": "",
"statusOffline": "",
"statusOnline": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {

View File

@@ -872,6 +872,7 @@
"resetDayMonthly": "Jour du mois (1–28)",
"resetDayWeekly": "Jour de la semaine (0=Dim)",
"resetting": "Réinitialisation...",
"resizeSidebar": "",
"result": "Résultat",
"resultCreated_one": "",
"resultCreated_other": "",
@@ -1907,7 +1908,6 @@
"title": "Créer une salle"
},
"dashboard": {
"brandName": "Fusion",
"initializingDashboard": "Initialisation du tableau de bord...",
"loadingMessage": "Chargement du tableau de bord Fusion",
"loadingProgress": "Progression du chargement du tableau de bord",
@@ -5458,10 +5458,18 @@
"cancel": "Annuler",
"save": "Enregistrer"
},
"agentPermissions": {
"agentPermissions": "",
"agentProvisioningApprovals": "",
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
},
"appearance": {
"hideAISessionNotificationBanners": "",
"language": "Langue",
"languageAuto": "Auto",
"languageAutoHint": "Suivre la langue du navigateur",
"suppressTheLdquoNeedsYourInputRdquoBanner": "",
"title": "Apparence"
},
"auth": {
@@ -5501,10 +5509,51 @@
"title": "Authentification",
"waitingForLogin": "En attente de connexion…"
},
"authentication": {
"enterAPIKey": "",
"key": ""
},
"backups": {
"02": "",
"03": "",
"agentsOnlyFusionAgentMemory": "",
"allProjectAgents": "",
"and": "",
"backupCreated": "Sauvegarde créée avec succès",
"backupDirectory": "",
"backupNow": "Sauvegarder maintenant",
"creating": "Création…"
"backups": "",
"backupS": "",
"backupScheduleCron": "",
"creating": "Création…",
"cronExpressionForBackupTimingDefault02": "",
"cronExpressionForMemoryBackupTimingDefault0": "",
"currentBackups": "",
"databaseBackups": "",
"directoryForBackupFilesRelativeToProjectRoot": "",
"directoryForMemoryBackupsRelativeToProjectRoot": "",
"enableAutomaticDatabaseBackups": "",
"enableAutomaticMemoryBackups": "",
"fusionBackups": "",
"fusionBackupsMemory": "",
"invalidCronExpressionFormat": "",
"loadingBackupInfo": "",
"memoryBackupDirectory": "",
"memoryBackups": "",
"memoryBackupScheduleCron": "",
"memoryBackupScope": "",
"memoryRetentionCount": "",
"more": "",
"mustBeBetween1And100": "",
"numberOfBackupFilesToKeepOldestAre": "",
"numberOfMemoryBackupsToKeepOldestAre": "",
"pathCannotContainParentDirectoryTraversal": "",
"projectOnlyFusionMemory": "",
"retentionCount": "",
"totalSize": "",
"view": "",
"whenEnabledProjectAndAgentMemoryFilesAre": "",
"whenEnabledTheDatabaseIsBackedUpAutomatically": ""
},
"clearToDefault": "",
"cliAgents": {
@@ -5531,14 +5580,80 @@
"saveFailed": ""
},
"closeModal": "Fermer la boîte de dialogue de conflit",
"commands": {
"buildCommand": "",
"commands": "",
"commandUsedToBuildTheProjectInjectedInto": "",
"commandUsedToRunTestsInjectedIntoGenerated": "",
"eGPnpmBuild": "",
"eGPnpmTest": "",
"testCommand": ""
},
"conflictModalTitle": "Résoudre les conflits de paramètres",
"experimental": {
"experimentalFeatures": "",
"experimentalFeaturesAreEarlyCapabilitiesThatAreNot": "",
"featureFlags": ""
},
"footer": {
"help": "Aide",
"version": "Version {{version}}"
},
"general": {
"25": "",
"200": "",
"3000": "",
"14Days": "",
"30Days": "",
"60Days": "",
"7Days": "",
"90Days": "",
"agentsToRunEachTaskWhenDisabledOnly": "",
"autoCleanupOldChats": "",
"autoPruneOldMail": "",
"bannerFiresWhenTodoCountIsStrictlyGreater": "",
"builtInWorkflows": "",
"capacityRiskBanner": "",
"chatHistory": "",
"chatRooms": "",
"completionDocumentationAutomation": "",
"controlsHowFutureTaskSpecsHandleReleaseNote": "",
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": "",
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "",
"defaultTrackingModeForNewTasks": "",
"deleteChatSessionsAndRoomsThatHaveBeen": "",
"deleteInboxOutboxMessagesOlderThanThisMany": "",
"disabledBuiltInWorkflowsAreHiddenFromWorkflow": "",
"enableSummarizationInProjectModelsToConfigureThatModel": "",
"fN": "",
"general": "",
"gitHubTracking": "",
"hardCapOnTheSynthesizedEarlierRoomContext": "",
"learnMore": "En savoir plus",
"loweringThisWindowMeansReliabilityMetricsChartsAnd": "",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "",
"numberOfMostRecentChatRoomMessagesKept": "",
"off": "",
"offDefault": "",
"onForNewTasks": "",
"operationalLogRetention": "",
"ownerRepo": "",
"prefixForNewTaskIDsEGKB": "",
"prefixMustBe110UppercaseLetters": "",
"projectDefaultTrackingRepo": "",
"recentVerbatimRoomMessages": "",
"requireChangelogUpdateExistingChangelog": "",
"requireChangesetChangesetMd": "",
"roomCompactionFetchLimit": "",
"roomSummaryMaxCharacters": "",
"searchTheTrackingRepoForLikelyDuplicatesBefore": "",
"settingsSaved": "Paramètres enregistrés",
"showCapacityRiskBanner": "",
"showQuickChatButton": "",
"showTheFloatingChatButtonInTheDashboard": "",
"taskPrefix": "",
"todoThreshold": "",
"trackingIssuesUseThisTaskAposSTitle": "",
"updateAvailablePrefix": "v{{version}} disponible",
"updateCheckFailed": "",
"updateFailed": "",
@@ -5547,7 +5662,88 @@
"updateSuccess": "",
"updateSuccessToast": "",
"updating": "",
"upToDate": "Vous êtes à jour ✓"
"upperBoundOnMessagesFetchedFromTheRoom": "",
"upToDate": "Vous êtes à jour ✓",
"useEphemeralTaskWorkerAgents": "",
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
"autoReloadDashboardOnVersionChange": "",
"checkForThe": "",
"checkForUpdatesAutomatically": "",
"cLIBinaryOnPATH": "",
"cLIBySpawning": "",
"controlsHowOftenTheDashboardReFetchesThe": "",
"dailyRecommended": "",
"disableThisIfYourLocalDevProcessIs": "",
"frequency": "",
"general": "",
"globalDefaultTrackingRepo": "",
"leaveBothThinkingTogglesOffToKeepThe": "",
"manualOnlyNeverAutoCheck": "",
"onStartupOncePerServerLaunch": "",
"ownerRepo": "",
"projectsInheritThisValueWhenTheyDoNot": "",
"rowsAndDoesNotAffectAssistantTextOr": "",
"saveAIThinkingForEphemeralTaskWorkerAgents": "",
"saveAIThinkingForPermanentAgents": "",
"saveAIThinkingLogs": "",
"saveToolOutputInAgentLogs": "",
"updates": "",
"weekly": "",
"whenDisabledToolRowsAreStillLoggedBut": "",
"whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "",
"whenEnabledFusionChecksNpmForNewVersions": "",
"whenEnabledTheDashboardProbesForAGlobally": ""
},
"globalModels": {
"allow": "",
"commaSeparatedValuesSentToOpenRouterModelSync": "",
"controlsHowMuchReasoningEffortTheAIModel": "",
"default": "",
"default2": "",
"defaultAIModelUsedForTaskExecutionWhen": "",
"defaultModel": "",
"deny": "",
"fallbackModel": "",
"flowAndPublishesThemUnderTheOpencodeGo": "",
"fusion": "",
"globalBaselineModelsForEachAIRoleProject": "",
"httpsRunfusionAi": "",
"latency": "",
"leaveEmptyToOmitThisHeaderDefaultFusion": "",
"leaveEmptyToOmitThisHeaderDefaultHttps": "",
"modelLanes": "",
"noFallback": "",
"openaiAnthropic": "",
"openRouterAdvanced": "",
"openRouterAllowFallbacks": "",
"openRouterHTTPReferer": "",
"openRouterOutputModalitiesFilter": "",
"openRouterRoutingIgnore": "",
"openRouterRoutingOnly": "",
"openRouterRoutingOrder": "",
"openRouterRoutingSort": "",
"openRouterSupportedParametersFilter": "",
"openRouterXTitle": "",
"price": "",
"providerName": "",
"requireParameters": "",
"startupModelSync": "",
"syncOpencodeGoModelListAtStartup": "",
"syncOpenRouterModelListAtStartup": "",
"text": "",
"thinkingEffort": "",
"throughput": "",
"toolsStructuredOutputs": "",
"usedAutomaticallyIfThePrimaryDefaultModelHits": "",
"useDefault": "",
"whenEnabledStartupFetchesTheLatestAvailableModels": "",
"whenEnabledStartupRefreshesModelsThroughTheLocal": ""
},
"header": {
"discord": "Discord"
@@ -5567,17 +5763,179 @@
"keepRemote": "Garder la version distante",
"loading": "Chargement…",
"memory": {
"03": "",
"agentsGetMemorySearchMemoryGetAndMemory": "",
"agentsSearchWithQmdFirstFallBackTo": "",
"automaticallyCompactMemoryWhenItExceedsTheThreshold": "",
"autoSummarizeMemory": "",
"bytesUpdated": "",
"cannotSave": "",
"checkingMemoryWriteAccess": "",
"compacting": "Compactage…",
"compactionThresholdChars": "",
"compactSelectedFile": "Compacter le fichier sélectionné",
"cronExpressionForAutoSummarizeScheduleDefaultDaily": "",
"cronExpressionForDreamProcessing": "",
"dreamCompleted": "Traitement des rêves terminé",
"dreaming": "",
"dreamNow": "Rêver maintenant",
"dreamSchedule": "",
"enableMemoryTools": "",
"failedToLoadBackendStatus": "",
"for": "",
"installing": "Installation…",
"installQmd": "Installer qmd",
"loadingMemory": "",
"manuallyTriggerDreamProcessingNow": "",
"memory": "",
"memoryCompacted": "Fichier mémoire compacté",
"memoryFile": "",
"memoryIsConfiguredWithAReadOnlyBackend": "",
"memoryIsCurrentlyDisabledYouCanViewThe": "",
"memoryLivesIn": "",
"memorySaved": "Mémoire enregistrée",
"memoryWillBeCompactedWhenItExceedsThis": "",
"noMatchingMemoryFound": "",
"processDreamsFromDailyMemory": "",
"qmd": "",
"qmdIsNotInstalledSearchWillUseLocal": "",
"result": "",
"runsTheSameQmdBackedMemorySearchPath": "",
"saveMemory": "Enregistrer la mémoire",
"scheduleCron": "",
"searchMemoryWithQmd": "",
"testing": "Test en cours…",
"testRetrieval": "Tester la récupération"
"testRetrieval": "Tester la récupération",
"turnsDailyNotesIntoDREAMSMdAndPromotes": ""
},
"merge": {
"abort": "",
"abortOneAIAttemptRequireManualResolutionIf": "",
"addFusionAsCoAuthorOnCommits": "",
"afterFusionAdvancesTheIntegrationBranchRefThe": "",
"aICorrectiveRoundsBeforeLandingTheBestResult": "",
"aIMerge": "",
"aIMergeDefaultAIMergesInAClean": "",
"aIModeMergesTheTaskBranchIntoAn": "",
"aIOnly": "",
"aIOnlyAIAutoResolveAIRetryNever": "",
"allowAIMergeToSyncADirtyChecked": "",
"alwaysPreserveDirectMergeCommitHistory": "",
"alwaysSquashDirectMerges": "",
"and": "",
"and2": "",
"areListedFirstOrChoose": "",
"autoCompletionMode": "",
"autoDetect": "",
"autoDetectOriginHEADMain": "",
"autoKeepsTodayAposSSquashBehaviorFor": "",
"autoMergeCompletedTasks": "",
"autoMergeConflictRetries": "",
"autoMergeRunsInTheTaskWorktreeBy": "",
"autoMergeWillRunRebaseConflictResolutionAnd": "",
"autoResolveConflictsInLockFilesAndGenerated": "",
"autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive": "",
"autoSyncProjectCheckoutAfterMerge": "",
"block": "",
"blockStrict": "",
"both": "",
"branchName": "",
"checkingForLegacyAutoMergeStamps": "",
"coAuthorEmail": "",
"coAuthorName": "",
"conflictFallbackStrategy": "",
"controlsThePostMergeAuditGate": "",
"controlsWhatHappensAfterATaskReachesIn": "",
"createMonitorAndMergeAGitHubPullRequest": "",
"custom": "",
"dangerousCompatibilityEscapeHatchLeaveOffUnlessYou": "",
"defaultLogsFindingsButAutoCompletesTheMerge": "",
"deterministicLegacyRebaseConflictStrategyAuditPipeline": "",
"directMergeCommitRouting": "",
"directMergeIntoTheCurrentBranch": "",
"emailUsedInThe": "",
"enableTestMode": "",
"fastForwardOfLocalMainFrom": "",
"fastForwardOnlySkipDirtyWorktrees": "",
"finalFallback": "",
"findsInReviewTasksWhoseAutoMergeValue": "",
"flipOverlappingFilesToPreferTheTaskBranch": "",
"forcesAllAILanesToUseTheDeterministic": "",
"fusion": "",
"gitHubAuthentication": "",
"gitHubAuthMode": "",
"gitHubCLIGhAuth": "",
"gitHubPersonalAccessToken": "",
"gitRemoteToPushToEGOrigin": "",
"ignoreOverlapDetectionPreserveLegacyBehavior": "",
"includeTaskIDInCommitScope": "",
"insteadOf": "",
"integrationBranch": "",
"integrationWorktree": "",
"inYourProjectRootWillShowTheNew": "",
"isTheLegacyBehavior": "",
"isTheStricterOptInModeThatRefuses": "",
"legacy": "",
"legacy2": "",
"legacyAutoMergeStamp": "",
"legacyAutoMergeStampCleanup": "",
"legacyIntegrationBranchMode": "",
"maxAIReviewPasses": "",
"merge": "",
"moreDetails": "",
"nameUsedInThe": "",
"noLegacyAutoMergeStampsToCleanUp": "",
"noreplyRunfusionAi": "",
"off": "",
"offLeaveTheProjectRootStaleLegacyBehavior": "",
"offSkipAudit": "",
"optionsStartWithABestEffort": "",
"origin": "",
"personalAccessToken": "",
"pickALocalBranchFromTheDropdownCommon": "",
"positiveIntegerRetryCapForAutoMergeConflict": "",
"postMergeAuditMode": "",
"pushRemote": "",
"pushToRemoteAfterMerge": "",
"readyToCleanUp": "",
"retriesTheAIAgentRatherThanAutoPicking": "",
"reuseTaskWorktreeDefault": "",
"skipsTheAuditEntirelySwitchingToOffIs": "",
"smart": "",
"smartConflictResolution": "",
"smartPreferMain": "",
"smartPreferMainOnFallbackFetchFfOrigin": "",
"smartPreferMainOverlapGuard": "",
"smartPreferTask": "",
"smartPreferTaskOnFallbackFetchFfOrigin": "",
"snapsCleanlyWhenTheWorktreeHasNoEdits": "",
"snapshotsRealLocalEditsAsAPatchAgainst": "",
"soAFreshlyPushedSiblingCommitDoesntGet": "",
"soMainWinsProtectsJustMergedSiblingWork": "",
"soTheTaskBranchWinsFastButCan": "",
"stashFastForwardDefaultPreserveLocalEdits": "",
"stopsAfterTheFirstAIAttemptAndWaits": "",
"symbolicRefFallback": "",
"theCanonicalBranchFusionMergesTasksIntoAnd": "",
"theLegacyMergeSettingsBelowDoNotApply": "",
"toResolveViaTheStandardCascade": "",
"toTypeABranchThatDoesnAposT": "",
"trailer": "",
"trailerCreditingFusionRecognizedByGitHubForShared": "",
"useDropdown": "",
"useProjectRootLegacy": "",
"uses": "",
"valuesFromOlderSettingsAreMigratedAutomatically": "",
"warn": "",
"warnDefaultLogFindingsContinue": "",
"warnOnlyKeepLegacyMainWinsFallback": "",
"whenDisabledMergeCommitMessagesOmitTheTask": "",
"whenEnabledCommitsMadeByFusionKeepYour": "",
"whenEnabledLockFilesPackageLockJsonPnpm": "",
"whenEnabledLockFilesPackageLockJsonPnpm2": "",
"whenEnabledTasksThatPassReviewAreAutomatically": "",
"whenEnabledTheMergedResultIsAutomaticallyPushed": "",
"whenUsingSmartPreferMainAutomaticallyPreferThe": ""
},
"mergeManually": "Fusionner manuellement",
"mobileNav": {
@@ -5610,38 +5968,314 @@
"project": "Spécifique à ce projet"
}
},
"nodeRouting": {
"blockExecution": "",
"configureHowTasksAreRoutedToExecutionNodes": "",
"defaultExecutionNode": "",
"fallBackToLocal": "",
"localExecutionNoDefaultNode": "",
"nodeRouting": "",
"selectedNode": "",
"theseSettingsApplyAtTheProjectLevel": "",
"unavailableNodePolicy": "",
"usedWhenATaskHasNoNodeOverride": ""
},
"nodeSync": {
"alwaysAsk": "",
"automaticallySynchronizeSettingsBetweenThisNodeAndConnected": "",
"conflictResolution": "",
"enableAutomaticSettingsSync": "",
"every15Minutes": "",
"every1Hour": "",
"every30Minutes": "",
"every5Minutes": "",
"includeAPIKeysAndOAuthTokensInSync": "",
"keepLocal": "",
"keepRemote": "",
"lastWriteWins": "",
"nodeSync": "",
"syncInterval": "",
"syncModelAuthCredentials": "",
"workflowSettingsNotSynced": ""
},
"notifications": {
"accessTokenOptional": "",
"advanced": "",
"allFailuresLegacy": "",
"baseURLForDeepLinksInNotificationsWhen": "",
"customNtfyServerURLOptional": "",
"dashboardHostname": "",
"discord": "",
"enable": "",
"failureNotificationDelayMs": "",
"failureNotificationMode": "",
"format": "",
"general": "",
"generic": "",
"howLongAFailureMustPersistBeforeA": "",
"httpLocalhost3000": "",
"httpsHooksExampleCom": "",
"httpsNtfySh": "",
"learnMoreAboutNtfySh": "",
"leaveBlankToKeepTheDefaultServerHttps": "",
"leaveBlankToPublishWithoutAuthenticationWhenSet": "",
"messageInbox": "",
"mustBeAValidURLStartingWithHttp": "",
"myTopicName": "",
"notifications": "",
"notifyOnEvents": "",
"ntfy": "",
"ntfyTopic": "",
"roomReply": "",
"sending": "Envoi…",
"slack": "",
"stickyFailuresOnlyDefault": "",
"stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits": "",
"terminalFailuresOnlySuppressAutoRetried": "",
"testMessageInbox": "Tester la boîte de réception des messages",
"testNotification": "Tester la notification",
"testRoomReply": "Tester la réponse en salle"
"testRoomReply": "Tester la réponse en salle",
"tk": "",
"topicMustBe164AlphanumericHyphenOr": "",
"webhook": "",
"webhookNotifications": "",
"webhookURL": "",
"yourNtfyShTopicName164Alphanumeric": ""
},
"plugins": {
"fusionPlugins": "",
"piExtensions": "",
"pluginManagerType": "",
"plugins": ""
},
"projectModels": {
"addPreset": "",
"advancedWorkflowPolicy": "",
"aIMergeCommitSummaries": "",
"aITitleAndGitCommitMessageSummarization": "",
"automaticallyCompactContextWhenApproachingThisTokenCount": "",
"autoSelectPresetBasedOnTaskSize": "",
"autoSummarizeLongDescriptionsAsTitles": "",
"configuredPresets": "",
"configuresTheModelUsedForTwoShortSummary": "",
"defaultWorkflowModelLaneActions": "",
"defaultWorkflowModelLanes": "",
"delete": "",
"edit": "",
"executorModel": "",
"fallsBackTo": "",
"loadingAvailableModels": "",
"loadingWorkflowModelLanes": "",
"modelLanes": "",
"modelPresets": "",
"name": "",
"noCap": "",
"noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow": "",
"noModelsAvailableConfigureAuthenticationFirst": "",
"noModelsAvailableConfigureAuthenticationFirst2": "",
"noPreset": "",
"noPresetsConfiguredYet": "",
"openAProjectToEditWorkflowModelLanes": "",
"overrideGlobalModelSettingsAtTheProjectLevel": "",
"presetEditor": "",
"reset": "",
"resetToDefaultNoCap": "",
"resetToInheritFromGlobal": "",
"resetToInheritFromWorkflow": "",
"reviewerModel": "",
"theseProjectOverridesApplyToTheActiveDefault": "",
"tokenCap": "",
"useDefault": "",
"useWorkflowDefault": "",
"whenEnabledMergeCommitMessagesIncludeAnAI": "",
"whenEnabledTasksCreatedWithoutATitleBut": ""
},
"remote": {
"acceptRoutes": "",
"advancedNamedTunnel": "",
"advancedSettings": "",
"authenticatedURL": "",
"authLinks": "",
"authLinkTokenType": "",
"automaticallyRestoreTunnelOnStartupIfItWas": "",
"cloudflare": "",
"cloudflaredInstalled": "cloudflared installé avec succès",
"cloudflaredIsInstalled": "",
"cloudflaredIsNotInstalled": "",
"cloudflaredMustBeInstalledToStartTheTunnel": "",
"enableShortLivedTokens": "",
"expiresAt": "",
"external": "",
"externalTunnelQRCode": "",
"generateQR": "",
"generateShortLivedToken": "",
"httpsYourDomainExample": "",
"ifHomebrewIsUnavailable": "",
"ingressURL": "",
"installCloudflared": "",
"installing": "",
"lastShortLivedTokenExpiresAt": "",
"manualInstall": "",
"ms": "",
"namedTunnelModeEnabled": "",
"noExpiry": "",
"persistentToken": "",
"persistentTokenRegenerated": "Jeton persistant régénéré",
"qRSVGMarkup": "",
"regeneratePersistentToken": "",
"rememberLastRunningState": "",
"remoteAccess": "",
"remoteAccessCode": "",
"remoteAccessQRCode": "",
"remoteProvider": "",
"restarting": "Redémarrage…",
"scanThisQRCodeOnYourPhone": "",
"scanToConnect": "",
"scanToOpen": "",
"selectAProviderAboveToConfigureRemoteAccess": "",
"shortLivedToken": "",
"shortLivedTokenGenerated": "Jeton de courte durée généré",
"shortLivedTTLMs": "",
"showURL": "",
"startFresh": "Redémarrer",
"starting": "Démarrage…",
"startTunnel": "Démarrer le tunnel",
"stopping": "Arrêt…",
"stopTunnel": "Arrêter le tunnel",
"tailnetURL": "",
"tailscale": "",
"tailscaleFunnelWillExposeThisDashboardOnYour": "",
"tokenType": "",
"tunnelDetected": "",
"tunnelName": "",
"tunnelRestarted": "Tunnel distant redémarré",
"tunnelStarted": "Tunnel distant démarré",
"tunnelStopped": "Tunnel distant arrêté",
"useExisting": "Utiliser l'existant"
"tunnelToken": "",
"uRLAndQRGenerationUseTheSelectedToken": "",
"uRLNoHostnameOrPortConfigurationNeeded": "",
"useExisting": "Utiliser l'existant",
"usingQuickTunnel": ""
},
"researchGlobal": {
"advancedExternalSearchProviders": "",
"alwaysOn": "",
"brave": "",
"builtInUsesAgentWebTools": "",
"configureBraveTavilyAndGoogleAPIKeysIn": "",
"customSearchEngineId": "",
"defaultMaxConcurrentRuns": "",
"defaultMaxDurationMs": "",
"defaultMaxSourcesPerRun": "",
"enabledSources": "",
"gitHub": "",
"googleCustomSearch": "",
"googleSearchCX": "",
"httpsSearxExampleCom": "",
"localDocs": "",
"maxSynthesisRounds": "",
"missingCredentialsForTheSelectedResearchProvider": "",
"openAuthentication": "",
"openAuthenticationSettings": "",
"requestTimeoutMs": "",
"researchDefaults": "",
"searchesAndFetchesUseTheAgentsNativeWebSearch": "",
"searchProvider": "",
"searXNG": "",
"searXNGURL": "",
"tavily": "",
"webSearch": ""
},
"researchProject": {
"alwaysOn": "",
"enabledSources": "",
"enableResearchInThisProject": "",
"github": "",
"llmSynthesis": "",
"localDocs": "",
"maxConcurrentRuns": "",
"maxDurationMs": "",
"maxSourcesPerRun": "",
"pageFetch": "",
"projectResearchSettings": "",
"requestTimeoutMs": "",
"webSearch": "",
"webSearchIsAlwaysEnabledConfigureTheSearch": ""
},
"resolveAllLocal": "Résoudre tous : Garder la version locale",
"resolveAllRemote": "Résoudre tous : Garder la version distante",
"resolvedSuccess": "Conflits de paramètres résolus avec succès",
"resolveFailed": "Impossible de résoudre les conflits",
"resolving": "Résolution...",
"runtimesRuntimes": {
"hermesRuntime": "",
"openClawRuntime": "",
"paperclipRuntime": ""
},
"scheduledEvals": {
"autoCreateTasks": "",
"disabled": "",
"enableScheduledEvalRunsForThisProject": "",
"evaluatorModel": "",
"evaluatorProvider": "",
"followUpPolicy": "",
"gpt5": "",
"intervalMs": "",
"leaveProviderAndModelBlankToInheritThe": "",
"openai": "",
"retentionDays": "",
"scheduledEvals": "",
"suggestOnly": ""
},
"scheduling": {
"addIgnoredPath": "",
"archiveAgentLog": "",
"archiveCompletedTasksAfterDays": "",
"backlogNoTaskAutoClaimIsExecutorOnly": "",
"browse": "",
"closeParenPeriod": "",
"compactModeKeepsArchiveSizeLowWhilePreserving": "",
"compactSummaryAndRecentEntries": "",
"completedTasksOlderThanTheThresholdAreMoved": "",
"docs": "",
"doNotArchiveAgentLogs": "",
"enableAutomaticTaskArchiving": "",
"enablePlanStalenessEnforcement": "",
"escalateHighFanOutBlockersOnlyAfterThey": "",
"fullAgentLog": "",
"globalMaxConcurrent": "",
"heartbeatScopeDiscipline": "",
"ignoredOverlapPaths": "",
"letEngineerAgentsAutoClaimBacklogTasks": "",
"lite": "",
"maxConcurrentTasks": "",
"maximumAgeInHoursBeforeAPlanIs": "",
"maximumConcurrentAgentsAcrossAllProjects": "",
"maximumConcurrentPlanningAgents": "",
"maximumStuckDetectorRetriesBeforeATaskIs": "",
"maxStuckRetries": "",
"maxTriageConcurrent": "",
"numberOfDaysATaskCanStayIn": "",
"off": "",
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
"or": "",
"pollIntervalMs": "",
"preserveStepProgressOnStuckTaskRequeue": "",
"remove": "",
"scheduling": "",
"selectCurrentDir": "Sélectionner le répertoire actuel",
"selectIgnoredOverlapPath": "Sélectionner le chemin de chevauchement ignoré"
"selectIgnoredOverlapPath": "Sélectionner le chemin de chevauchement ignoré",
"serializeTasksWithOverlappingFiles": "",
"staleHighFanOutEscalationHours": "",
"staleSpecThresholdHours": "",
"stepExecution": "",
"strictCoordinationFocusedHigherPerTickTokensLite": "",
"strictDefault": "",
"stuckTaskTimeoutMinutes": "",
"timeoutInMinutesForDetectingStuckTasksWhen": "",
"whenEnabledTasksThatModifyTheSameFiles": "",
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": ""
},
"scope": {
"globalBanner": "Ces paramètres sont partagés entre tous vos projets Fusion.",
@@ -5649,11 +6283,59 @@
},
"title": "Paramètres",
"worktrees": {
"allowSilentSiblingBranchRenameDuringExecutorConflicts": "",
"alsoRebaseOntoLocalDefaultBranchHEAD": "",
"and": "",
"andCanHidePriorCommitsFromTheDefault": "",
"autoDetectFusionBinWorktrunkOrPATH": "",
"awaitingApproval": "En attente d'approbation — ouvrez les approbations pour continuer.",
"branchCollisionSilentlyForksWorkOntoSiblingBranches": "",
"browse": "",
"browseWorktreesDirectory": "",
"closeParenPeriod": "",
"defaultsTo": "",
"defaultsToWorktreesLeaveEmptyUnlessOverriding": "",
"disabledByDefaultOptInWhenEnabledFusion": "",
"discouragedThisRestoresTheLegacyBehaviorWhereA": "",
"enableWorktrunkAndRequestApprovalToInstallThe": "",
"enableWorktrunkIntegration": "",
"failAndPauseTheTaskDefault": "",
"fallBackToFusionsNativeWorktreeBackend": "",
"forWorktreeCreateSyncPruneAndRemoveOperations": "",
"inAdditionToTheRemoteRebaseAboveAlso": "",
"installedAt": "",
"installTheWorktrunkBinaryBelowToEnableThis": "",
"installWorktrunk": "Installer le binaire worktrunk",
"keepsProgressMovingBySwitchingToFusionApos": "",
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
"maxWorktrees": "",
"offByDefaultOptInWhenEnabledCompleted": "",
"openApprovals": "Ouvrir les approbations",
"optionalLeaveBlankToAutoResolveFusionWill": "",
"optionalSupports": "",
"pnpmInstallFrozenLockfile": "",
"randomNamesEGSwiftFalcon": "",
"rebaseFromRemoteBeforeMerge": "",
"rebaseRemote": "",
"recycleWorktrees": "",
"selectWorktreesDir": "Sélectionner le répertoire des worktrees",
"tryAgain": "Réessayer"
"shellCommandToRunInEachNewWorktree": "",
"stopsOnWorktrunkErrorsForExplicitOperatorRecovery": "",
"taskIDEGFN042": "",
"taskTitleEGFixLoginBug": "",
"tryAgain": "Réessayer",
"useGitDefault": "",
"whenEnabledTheMergerFetchesFromTheConfigured": "",
"whenUnsetOnlyAffectsNewlyCreatedWorktrees": "",
"whichRemoteToFetchForThePreMerge": "",
"worktreeInitCommand": "",
"worktreeNamingStyle": "",
"worktrees": "",
"worktreesDirectory": "",
"worktrunk": "",
"worktrunkBinaryPath": "",
"worktrunkFailureBehavior": "",
"worktrunkIntegration": ""
}
},
"setup": {

View File

@@ -142,6 +142,14 @@
"notConfigured": ""
}
},
"settings": {
"nodeRouting": {
"statusConnecting": "",
"statusError": "",
"statusOffline": "",
"statusOnline": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {

View File

@@ -872,6 +872,7 @@
"resetDayMonthly": "날짜 (1–28)",
"resetDayWeekly": "요일 (0=일요일)",
"resetting": "초기화 중...",
"resizeSidebar": "",
"result": "결과",
"resultCreated_one": "",
"resultCreated_other": "",
@@ -1907,7 +1908,6 @@
"title": "방 만들기"
},
"dashboard": {
"brandName": "Fusion",
"initializingDashboard": "대시보드 초기화 중...",
"loadingMessage": "Fusion 대시보드 로드 중",
"loadingProgress": "대시보드 로딩 진행 상황",
@@ -5458,10 +5458,18 @@
"cancel": "취소",
"save": "저장"
},
"agentPermissions": {
"agentPermissions": "",
"agentProvisioningApprovals": "",
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
},
"appearance": {
"hideAISessionNotificationBanners": "",
"language": "언어",
"languageAuto": "자동",
"languageAutoHint": "브라우저 언어를 따릅니다",
"suppressTheLdquoNeedsYourInputRdquoBanner": "",
"title": "모양"
},
"auth": {
@@ -5501,10 +5509,51 @@
"title": "인증",
"waitingForLogin": "로그인을 기다리는 중…"
},
"authentication": {
"enterAPIKey": "",
"key": ""
},
"backups": {
"02": "",
"03": "",
"agentsOnlyFusionAgentMemory": "",
"allProjectAgents": "",
"and": "",
"backupCreated": "백업이 성공적으로 생성되었습니다",
"backupDirectory": "",
"backupNow": "지금 백업",
"creating": "생성 중…"
"backups": "",
"backupS": "",
"backupScheduleCron": "",
"creating": "생성 중…",
"cronExpressionForBackupTimingDefault02": "",
"cronExpressionForMemoryBackupTimingDefault0": "",
"currentBackups": "",
"databaseBackups": "",
"directoryForBackupFilesRelativeToProjectRoot": "",
"directoryForMemoryBackupsRelativeToProjectRoot": "",
"enableAutomaticDatabaseBackups": "",
"enableAutomaticMemoryBackups": "",
"fusionBackups": "",
"fusionBackupsMemory": "",
"invalidCronExpressionFormat": "",
"loadingBackupInfo": "",
"memoryBackupDirectory": "",
"memoryBackups": "",
"memoryBackupScheduleCron": "",
"memoryBackupScope": "",
"memoryRetentionCount": "",
"more": "",
"mustBeBetween1And100": "",
"numberOfBackupFilesToKeepOldestAre": "",
"numberOfMemoryBackupsToKeepOldestAre": "",
"pathCannotContainParentDirectoryTraversal": "",
"projectOnlyFusionMemory": "",
"retentionCount": "",
"totalSize": "",
"view": "",
"whenEnabledProjectAndAgentMemoryFilesAre": "",
"whenEnabledTheDatabaseIsBackedUpAutomatically": ""
},
"clearToDefault": "",
"cliAgents": {
@@ -5531,14 +5580,80 @@
"saveFailed": ""
},
"closeModal": "충돌 모달 닫기",
"commands": {
"buildCommand": "",
"commands": "",
"commandUsedToBuildTheProjectInjectedInto": "",
"commandUsedToRunTestsInjectedIntoGenerated": "",
"eGPnpmBuild": "",
"eGPnpmTest": "",
"testCommand": ""
},
"conflictModalTitle": "설정 충돌 해결",
"experimental": {
"experimentalFeatures": "",
"experimentalFeaturesAreEarlyCapabilitiesThatAreNot": "",
"featureFlags": ""
},
"footer": {
"help": "도움말",
"version": "버전 {{version}}"
},
"general": {
"25": "",
"200": "",
"3000": "",
"14Days": "",
"30Days": "",
"60Days": "",
"7Days": "",
"90Days": "",
"agentsToRunEachTaskWhenDisabledOnly": "",
"autoCleanupOldChats": "",
"autoPruneOldMail": "",
"bannerFiresWhenTodoCountIsStrictlyGreater": "",
"builtInWorkflows": "",
"capacityRiskBanner": "",
"chatHistory": "",
"chatRooms": "",
"completionDocumentationAutomation": "",
"controlsHowFutureTaskSpecsHandleReleaseNote": "",
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": "",
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "",
"defaultTrackingModeForNewTasks": "",
"deleteChatSessionsAndRoomsThatHaveBeen": "",
"deleteInboxOutboxMessagesOlderThanThisMany": "",
"disabledBuiltInWorkflowsAreHiddenFromWorkflow": "",
"enableSummarizationInProjectModelsToConfigureThatModel": "",
"fN": "",
"general": "",
"gitHubTracking": "",
"hardCapOnTheSynthesizedEarlierRoomContext": "",
"learnMore": "더 알아보기",
"loweringThisWindowMeansReliabilityMetricsChartsAnd": "",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "",
"numberOfMostRecentChatRoomMessagesKept": "",
"off": "",
"offDefault": "",
"onForNewTasks": "",
"operationalLogRetention": "",
"ownerRepo": "",
"prefixForNewTaskIDsEGKB": "",
"prefixMustBe110UppercaseLetters": "",
"projectDefaultTrackingRepo": "",
"recentVerbatimRoomMessages": "",
"requireChangelogUpdateExistingChangelog": "",
"requireChangesetChangesetMd": "",
"roomCompactionFetchLimit": "",
"roomSummaryMaxCharacters": "",
"searchTheTrackingRepoForLikelyDuplicatesBefore": "",
"settingsSaved": "설정이 저장되었습니다",
"showCapacityRiskBanner": "",
"showQuickChatButton": "",
"showTheFloatingChatButtonInTheDashboard": "",
"taskPrefix": "",
"todoThreshold": "",
"trackingIssuesUseThisTaskAposSTitle": "",
"updateAvailablePrefix": "v{{version}} 사용 가능",
"updateCheckFailed": "",
"updateFailed": "",
@@ -5547,7 +5662,88 @@
"updateSuccess": "",
"updateSuccessToast": "",
"updating": "",
"upToDate": "최신 버전입니다 ✓"
"upperBoundOnMessagesFetchedFromTheRoom": "",
"upToDate": "최신 버전입니다 ✓",
"useEphemeralTaskWorkerAgents": "",
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
"autoReloadDashboardOnVersionChange": "",
"checkForThe": "",
"checkForUpdatesAutomatically": "",
"cLIBinaryOnPATH": "",
"cLIBySpawning": "",
"controlsHowOftenTheDashboardReFetchesThe": "",
"dailyRecommended": "",
"disableThisIfYourLocalDevProcessIs": "",
"frequency": "",
"general": "",
"globalDefaultTrackingRepo": "",
"leaveBothThinkingTogglesOffToKeepThe": "",
"manualOnlyNeverAutoCheck": "",
"onStartupOncePerServerLaunch": "",
"ownerRepo": "",
"projectsInheritThisValueWhenTheyDoNot": "",
"rowsAndDoesNotAffectAssistantTextOr": "",
"saveAIThinkingForEphemeralTaskWorkerAgents": "",
"saveAIThinkingForPermanentAgents": "",
"saveAIThinkingLogs": "",
"saveToolOutputInAgentLogs": "",
"updates": "",
"weekly": "",
"whenDisabledToolRowsAreStillLoggedBut": "",
"whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "",
"whenEnabledFusionChecksNpmForNewVersions": "",
"whenEnabledTheDashboardProbesForAGlobally": ""
},
"globalModels": {
"allow": "",
"commaSeparatedValuesSentToOpenRouterModelSync": "",
"controlsHowMuchReasoningEffortTheAIModel": "",
"default": "",
"default2": "",
"defaultAIModelUsedForTaskExecutionWhen": "",
"defaultModel": "",
"deny": "",
"fallbackModel": "",
"flowAndPublishesThemUnderTheOpencodeGo": "",
"fusion": "",
"globalBaselineModelsForEachAIRoleProject": "",
"httpsRunfusionAi": "",
"latency": "",
"leaveEmptyToOmitThisHeaderDefaultFusion": "",
"leaveEmptyToOmitThisHeaderDefaultHttps": "",
"modelLanes": "",
"noFallback": "",
"openaiAnthropic": "",
"openRouterAdvanced": "",
"openRouterAllowFallbacks": "",
"openRouterHTTPReferer": "",
"openRouterOutputModalitiesFilter": "",
"openRouterRoutingIgnore": "",
"openRouterRoutingOnly": "",
"openRouterRoutingOrder": "",
"openRouterRoutingSort": "",
"openRouterSupportedParametersFilter": "",
"openRouterXTitle": "",
"price": "",
"providerName": "",
"requireParameters": "",
"startupModelSync": "",
"syncOpencodeGoModelListAtStartup": "",
"syncOpenRouterModelListAtStartup": "",
"text": "",
"thinkingEffort": "",
"throughput": "",
"toolsStructuredOutputs": "",
"usedAutomaticallyIfThePrimaryDefaultModelHits": "",
"useDefault": "",
"whenEnabledStartupFetchesTheLatestAvailableModels": "",
"whenEnabledStartupRefreshesModelsThroughTheLocal": ""
},
"header": {
"discord": "Discord"
@@ -5567,17 +5763,179 @@
"keepRemote": "원격 유지",
"loading": "불러오는 중…",
"memory": {
"03": "",
"agentsGetMemorySearchMemoryGetAndMemory": "",
"agentsSearchWithQmdFirstFallBackTo": "",
"automaticallyCompactMemoryWhenItExceedsTheThreshold": "",
"autoSummarizeMemory": "",
"bytesUpdated": "",
"cannotSave": "",
"checkingMemoryWriteAccess": "",
"compacting": "압축 중…",
"compactionThresholdChars": "",
"compactSelectedFile": "선택한 파일 압축",
"cronExpressionForAutoSummarizeScheduleDefaultDaily": "",
"cronExpressionForDreamProcessing": "",
"dreamCompleted": "드림 처리가 완료되었습니다",
"dreaming": "",
"dreamNow": "지금 드림",
"dreamSchedule": "",
"enableMemoryTools": "",
"failedToLoadBackendStatus": "",
"for": "",
"installing": "설치 중…",
"installQmd": "qmd 설치",
"loadingMemory": "",
"manuallyTriggerDreamProcessingNow": "",
"memory": "",
"memoryCompacted": "메모리 파일이 압축되었습니다",
"memoryFile": "",
"memoryIsConfiguredWithAReadOnlyBackend": "",
"memoryIsCurrentlyDisabledYouCanViewThe": "",
"memoryLivesIn": "",
"memorySaved": "메모리가 저장되었습니다",
"memoryWillBeCompactedWhenItExceedsThis": "",
"noMatchingMemoryFound": "",
"processDreamsFromDailyMemory": "",
"qmd": "",
"qmdIsNotInstalledSearchWillUseLocal": "",
"result": "",
"runsTheSameQmdBackedMemorySearchPath": "",
"saveMemory": "메모리 저장",
"scheduleCron": "",
"searchMemoryWithQmd": "",
"testing": "테스트 중…",
"testRetrieval": "검색 테스트"
"testRetrieval": "검색 테스트",
"turnsDailyNotesIntoDREAMSMdAndPromotes": ""
},
"merge": {
"abort": "",
"abortOneAIAttemptRequireManualResolutionIf": "",
"addFusionAsCoAuthorOnCommits": "",
"afterFusionAdvancesTheIntegrationBranchRefThe": "",
"aICorrectiveRoundsBeforeLandingTheBestResult": "",
"aIMerge": "",
"aIMergeDefaultAIMergesInAClean": "",
"aIModeMergesTheTaskBranchIntoAn": "",
"aIOnly": "",
"aIOnlyAIAutoResolveAIRetryNever": "",
"allowAIMergeToSyncADirtyChecked": "",
"alwaysPreserveDirectMergeCommitHistory": "",
"alwaysSquashDirectMerges": "",
"and": "",
"and2": "",
"areListedFirstOrChoose": "",
"autoCompletionMode": "",
"autoDetect": "",
"autoDetectOriginHEADMain": "",
"autoKeepsTodayAposSSquashBehaviorFor": "",
"autoMergeCompletedTasks": "",
"autoMergeConflictRetries": "",
"autoMergeRunsInTheTaskWorktreeBy": "",
"autoMergeWillRunRebaseConflictResolutionAnd": "",
"autoResolveConflictsInLockFilesAndGenerated": "",
"autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive": "",
"autoSyncProjectCheckoutAfterMerge": "",
"block": "",
"blockStrict": "",
"both": "",
"branchName": "",
"checkingForLegacyAutoMergeStamps": "",
"coAuthorEmail": "",
"coAuthorName": "",
"conflictFallbackStrategy": "",
"controlsThePostMergeAuditGate": "",
"controlsWhatHappensAfterATaskReachesIn": "",
"createMonitorAndMergeAGitHubPullRequest": "",
"custom": "",
"dangerousCompatibilityEscapeHatchLeaveOffUnlessYou": "",
"defaultLogsFindingsButAutoCompletesTheMerge": "",
"deterministicLegacyRebaseConflictStrategyAuditPipeline": "",
"directMergeCommitRouting": "",
"directMergeIntoTheCurrentBranch": "",
"emailUsedInThe": "",
"enableTestMode": "",
"fastForwardOfLocalMainFrom": "",
"fastForwardOnlySkipDirtyWorktrees": "",
"finalFallback": "",
"findsInReviewTasksWhoseAutoMergeValue": "",
"flipOverlappingFilesToPreferTheTaskBranch": "",
"forcesAllAILanesToUseTheDeterministic": "",
"fusion": "",
"gitHubAuthentication": "",
"gitHubAuthMode": "",
"gitHubCLIGhAuth": "",
"gitHubPersonalAccessToken": "",
"gitRemoteToPushToEGOrigin": "",
"ignoreOverlapDetectionPreserveLegacyBehavior": "",
"includeTaskIDInCommitScope": "",
"insteadOf": "",
"integrationBranch": "",
"integrationWorktree": "",
"inYourProjectRootWillShowTheNew": "",
"isTheLegacyBehavior": "",
"isTheStricterOptInModeThatRefuses": "",
"legacy": "",
"legacy2": "",
"legacyAutoMergeStamp": "",
"legacyAutoMergeStampCleanup": "",
"legacyIntegrationBranchMode": "",
"maxAIReviewPasses": "",
"merge": "",
"moreDetails": "",
"nameUsedInThe": "",
"noLegacyAutoMergeStampsToCleanUp": "",
"noreplyRunfusionAi": "",
"off": "",
"offLeaveTheProjectRootStaleLegacyBehavior": "",
"offSkipAudit": "",
"optionsStartWithABestEffort": "",
"origin": "",
"personalAccessToken": "",
"pickALocalBranchFromTheDropdownCommon": "",
"positiveIntegerRetryCapForAutoMergeConflict": "",
"postMergeAuditMode": "",
"pushRemote": "",
"pushToRemoteAfterMerge": "",
"readyToCleanUp": "",
"retriesTheAIAgentRatherThanAutoPicking": "",
"reuseTaskWorktreeDefault": "",
"skipsTheAuditEntirelySwitchingToOffIs": "",
"smart": "",
"smartConflictResolution": "",
"smartPreferMain": "",
"smartPreferMainOnFallbackFetchFfOrigin": "",
"smartPreferMainOverlapGuard": "",
"smartPreferTask": "",
"smartPreferTaskOnFallbackFetchFfOrigin": "",
"snapsCleanlyWhenTheWorktreeHasNoEdits": "",
"snapshotsRealLocalEditsAsAPatchAgainst": "",
"soAFreshlyPushedSiblingCommitDoesntGet": "",
"soMainWinsProtectsJustMergedSiblingWork": "",
"soTheTaskBranchWinsFastButCan": "",
"stashFastForwardDefaultPreserveLocalEdits": "",
"stopsAfterTheFirstAIAttemptAndWaits": "",
"symbolicRefFallback": "",
"theCanonicalBranchFusionMergesTasksIntoAnd": "",
"theLegacyMergeSettingsBelowDoNotApply": "",
"toResolveViaTheStandardCascade": "",
"toTypeABranchThatDoesnAposT": "",
"trailer": "",
"trailerCreditingFusionRecognizedByGitHubForShared": "",
"useDropdown": "",
"useProjectRootLegacy": "",
"uses": "",
"valuesFromOlderSettingsAreMigratedAutomatically": "",
"warn": "",
"warnDefaultLogFindingsContinue": "",
"warnOnlyKeepLegacyMainWinsFallback": "",
"whenDisabledMergeCommitMessagesOmitTheTask": "",
"whenEnabledCommitsMadeByFusionKeepYour": "",
"whenEnabledLockFilesPackageLockJsonPnpm": "",
"whenEnabledLockFilesPackageLockJsonPnpm2": "",
"whenEnabledTasksThatPassReviewAreAutomatically": "",
"whenEnabledTheMergedResultIsAutomaticallyPushed": "",
"whenUsingSmartPreferMainAutomaticallyPreferThe": ""
},
"mergeManually": "수동으로 병합",
"mobileNav": {
@@ -5610,38 +5968,314 @@
"project": "이 프로젝트에만 적용됨"
}
},
"nodeRouting": {
"blockExecution": "",
"configureHowTasksAreRoutedToExecutionNodes": "",
"defaultExecutionNode": "",
"fallBackToLocal": "",
"localExecutionNoDefaultNode": "",
"nodeRouting": "",
"selectedNode": "",
"theseSettingsApplyAtTheProjectLevel": "",
"unavailableNodePolicy": "",
"usedWhenATaskHasNoNodeOverride": ""
},
"nodeSync": {
"alwaysAsk": "",
"automaticallySynchronizeSettingsBetweenThisNodeAndConnected": "",
"conflictResolution": "",
"enableAutomaticSettingsSync": "",
"every15Minutes": "",
"every1Hour": "",
"every30Minutes": "",
"every5Minutes": "",
"includeAPIKeysAndOAuthTokensInSync": "",
"keepLocal": "",
"keepRemote": "",
"lastWriteWins": "",
"nodeSync": "",
"syncInterval": "",
"syncModelAuthCredentials": "",
"workflowSettingsNotSynced": ""
},
"notifications": {
"accessTokenOptional": "",
"advanced": "",
"allFailuresLegacy": "",
"baseURLForDeepLinksInNotificationsWhen": "",
"customNtfyServerURLOptional": "",
"dashboardHostname": "",
"discord": "",
"enable": "",
"failureNotificationDelayMs": "",
"failureNotificationMode": "",
"format": "",
"general": "",
"generic": "",
"howLongAFailureMustPersistBeforeA": "",
"httpLocalhost3000": "",
"httpsHooksExampleCom": "",
"httpsNtfySh": "",
"learnMoreAboutNtfySh": "",
"leaveBlankToKeepTheDefaultServerHttps": "",
"leaveBlankToPublishWithoutAuthenticationWhenSet": "",
"messageInbox": "",
"mustBeAValidURLStartingWithHttp": "",
"myTopicName": "",
"notifications": "",
"notifyOnEvents": "",
"ntfy": "",
"ntfyTopic": "",
"roomReply": "",
"sending": "전송 중…",
"slack": "",
"stickyFailuresOnlyDefault": "",
"stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits": "",
"terminalFailuresOnlySuppressAutoRetried": "",
"testMessageInbox": "테스트 메시지 받은 편지함",
"testNotification": "테스트 알림",
"testRoomReply": "테스트 룸 답장"
"testRoomReply": "테스트 룸 답장",
"tk": "",
"topicMustBe164AlphanumericHyphenOr": "",
"webhook": "",
"webhookNotifications": "",
"webhookURL": "",
"yourNtfyShTopicName164Alphanumeric": ""
},
"plugins": {
"fusionPlugins": "",
"piExtensions": "",
"pluginManagerType": "",
"plugins": ""
},
"projectModels": {
"addPreset": "",
"advancedWorkflowPolicy": "",
"aIMergeCommitSummaries": "",
"aITitleAndGitCommitMessageSummarization": "",
"automaticallyCompactContextWhenApproachingThisTokenCount": "",
"autoSelectPresetBasedOnTaskSize": "",
"autoSummarizeLongDescriptionsAsTitles": "",
"configuredPresets": "",
"configuresTheModelUsedForTwoShortSummary": "",
"defaultWorkflowModelLaneActions": "",
"defaultWorkflowModelLanes": "",
"delete": "",
"edit": "",
"executorModel": "",
"fallsBackTo": "",
"loadingAvailableModels": "",
"loadingWorkflowModelLanes": "",
"modelLanes": "",
"modelPresets": "",
"name": "",
"noCap": "",
"noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow": "",
"noModelsAvailableConfigureAuthenticationFirst": "",
"noModelsAvailableConfigureAuthenticationFirst2": "",
"noPreset": "",
"noPresetsConfiguredYet": "",
"openAProjectToEditWorkflowModelLanes": "",
"overrideGlobalModelSettingsAtTheProjectLevel": "",
"presetEditor": "",
"reset": "",
"resetToDefaultNoCap": "",
"resetToInheritFromGlobal": "",
"resetToInheritFromWorkflow": "",
"reviewerModel": "",
"theseProjectOverridesApplyToTheActiveDefault": "",
"tokenCap": "",
"useDefault": "",
"useWorkflowDefault": "",
"whenEnabledMergeCommitMessagesIncludeAnAI": "",
"whenEnabledTasksCreatedWithoutATitleBut": ""
},
"remote": {
"acceptRoutes": "",
"advancedNamedTunnel": "",
"advancedSettings": "",
"authenticatedURL": "",
"authLinks": "",
"authLinkTokenType": "",
"automaticallyRestoreTunnelOnStartupIfItWas": "",
"cloudflare": "",
"cloudflaredInstalled": "cloudflared가 성공적으로 설치되었습니다",
"cloudflaredIsInstalled": "",
"cloudflaredIsNotInstalled": "",
"cloudflaredMustBeInstalledToStartTheTunnel": "",
"enableShortLivedTokens": "",
"expiresAt": "",
"external": "",
"externalTunnelQRCode": "",
"generateQR": "",
"generateShortLivedToken": "",
"httpsYourDomainExample": "",
"ifHomebrewIsUnavailable": "",
"ingressURL": "",
"installCloudflared": "",
"installing": "",
"lastShortLivedTokenExpiresAt": "",
"manualInstall": "",
"ms": "",
"namedTunnelModeEnabled": "",
"noExpiry": "",
"persistentToken": "",
"persistentTokenRegenerated": "영구 토큰이 재생성되었습니다",
"qRSVGMarkup": "",
"regeneratePersistentToken": "",
"rememberLastRunningState": "",
"remoteAccess": "",
"remoteAccessCode": "",
"remoteAccessQRCode": "",
"remoteProvider": "",
"restarting": "재시작 중…",
"scanThisQRCodeOnYourPhone": "",
"scanToConnect": "",
"scanToOpen": "",
"selectAProviderAboveToConfigureRemoteAccess": "",
"shortLivedToken": "",
"shortLivedTokenGenerated": "단기 토큰이 생성되었습니다",
"shortLivedTTLMs": "",
"showURL": "",
"startFresh": "새로 시작",
"starting": "시작 중…",
"startTunnel": "터널 시작",
"stopping": "중지 중…",
"stopTunnel": "터널 중지",
"tailnetURL": "",
"tailscale": "",
"tailscaleFunnelWillExposeThisDashboardOnYour": "",
"tokenType": "",
"tunnelDetected": "",
"tunnelName": "",
"tunnelRestarted": "원격 터널이 재시작되었습니다",
"tunnelStarted": "원격 터널이 시작되었습니다",
"tunnelStopped": "원격 터널이 중지되었습니다",
"useExisting": "기존 사용"
"tunnelToken": "",
"uRLAndQRGenerationUseTheSelectedToken": "",
"uRLNoHostnameOrPortConfigurationNeeded": "",
"useExisting": "기존 사용",
"usingQuickTunnel": ""
},
"researchGlobal": {
"advancedExternalSearchProviders": "",
"alwaysOn": "",
"brave": "",
"builtInUsesAgentWebTools": "",
"configureBraveTavilyAndGoogleAPIKeysIn": "",
"customSearchEngineId": "",
"defaultMaxConcurrentRuns": "",
"defaultMaxDurationMs": "",
"defaultMaxSourcesPerRun": "",
"enabledSources": "",
"gitHub": "",
"googleCustomSearch": "",
"googleSearchCX": "",
"httpsSearxExampleCom": "",
"localDocs": "",
"maxSynthesisRounds": "",
"missingCredentialsForTheSelectedResearchProvider": "",
"openAuthentication": "",
"openAuthenticationSettings": "",
"requestTimeoutMs": "",
"researchDefaults": "",
"searchesAndFetchesUseTheAgentsNativeWebSearch": "",
"searchProvider": "",
"searXNG": "",
"searXNGURL": "",
"tavily": "",
"webSearch": ""
},
"researchProject": {
"alwaysOn": "",
"enabledSources": "",
"enableResearchInThisProject": "",
"github": "",
"llmSynthesis": "",
"localDocs": "",
"maxConcurrentRuns": "",
"maxDurationMs": "",
"maxSourcesPerRun": "",
"pageFetch": "",
"projectResearchSettings": "",
"requestTimeoutMs": "",
"webSearch": "",
"webSearchIsAlwaysEnabledConfigureTheSearch": ""
},
"resolveAllLocal": "모두 해결: 로컬 유지",
"resolveAllRemote": "모두 해결: 원격 유지",
"resolvedSuccess": "설정 충돌이 성공적으로 해결되었습니다",
"resolveFailed": "충돌 해결에 실패했습니다",
"resolving": "해결 중...",
"runtimesRuntimes": {
"hermesRuntime": "",
"openClawRuntime": "",
"paperclipRuntime": ""
},
"scheduledEvals": {
"autoCreateTasks": "",
"disabled": "",
"enableScheduledEvalRunsForThisProject": "",
"evaluatorModel": "",
"evaluatorProvider": "",
"followUpPolicy": "",
"gpt5": "",
"intervalMs": "",
"leaveProviderAndModelBlankToInheritThe": "",
"openai": "",
"retentionDays": "",
"scheduledEvals": "",
"suggestOnly": ""
},
"scheduling": {
"addIgnoredPath": "",
"archiveAgentLog": "",
"archiveCompletedTasksAfterDays": "",
"backlogNoTaskAutoClaimIsExecutorOnly": "",
"browse": "",
"closeParenPeriod": "",
"compactModeKeepsArchiveSizeLowWhilePreserving": "",
"compactSummaryAndRecentEntries": "",
"completedTasksOlderThanTheThresholdAreMoved": "",
"docs": "",
"doNotArchiveAgentLogs": "",
"enableAutomaticTaskArchiving": "",
"enablePlanStalenessEnforcement": "",
"escalateHighFanOutBlockersOnlyAfterThey": "",
"fullAgentLog": "",
"globalMaxConcurrent": "",
"heartbeatScopeDiscipline": "",
"ignoredOverlapPaths": "",
"letEngineerAgentsAutoClaimBacklogTasks": "",
"lite": "",
"maxConcurrentTasks": "",
"maximumAgeInHoursBeforeAPlanIs": "",
"maximumConcurrentAgentsAcrossAllProjects": "",
"maximumConcurrentPlanningAgents": "",
"maximumStuckDetectorRetriesBeforeATaskIs": "",
"maxStuckRetries": "",
"maxTriageConcurrent": "",
"numberOfDaysATaskCanStayIn": "",
"off": "",
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
"or": "",
"pollIntervalMs": "",
"preserveStepProgressOnStuckTaskRequeue": "",
"remove": "",
"scheduling": "",
"selectCurrentDir": "현재 디렉터리 선택",
"selectIgnoredOverlapPath": "무시된 겹침 경로 선택"
"selectIgnoredOverlapPath": "무시된 겹침 경로 선택",
"serializeTasksWithOverlappingFiles": "",
"staleHighFanOutEscalationHours": "",
"staleSpecThresholdHours": "",
"stepExecution": "",
"strictCoordinationFocusedHigherPerTickTokensLite": "",
"strictDefault": "",
"stuckTaskTimeoutMinutes": "",
"timeoutInMinutesForDetectingStuckTasksWhen": "",
"whenEnabledTasksThatModifyTheSameFiles": "",
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": ""
},
"scope": {
"globalBanner": "이 설정은 모든 Fusion 프로젝트에서 공유됩니다.",
@@ -5649,11 +6283,59 @@
},
"title": "설정",
"worktrees": {
"allowSilentSiblingBranchRenameDuringExecutorConflicts": "",
"alsoRebaseOntoLocalDefaultBranchHEAD": "",
"and": "",
"andCanHidePriorCommitsFromTheDefault": "",
"autoDetectFusionBinWorktrunkOrPATH": "",
"awaitingApproval": "승인 대기 중 — 승인을 열어 계속하세요.",
"branchCollisionSilentlyForksWorkOntoSiblingBranches": "",
"browse": "",
"browseWorktreesDirectory": "",
"closeParenPeriod": "",
"defaultsTo": "",
"defaultsToWorktreesLeaveEmptyUnlessOverriding": "",
"disabledByDefaultOptInWhenEnabledFusion": "",
"discouragedThisRestoresTheLegacyBehaviorWhereA": "",
"enableWorktrunkAndRequestApprovalToInstallThe": "",
"enableWorktrunkIntegration": "",
"failAndPauseTheTaskDefault": "",
"fallBackToFusionsNativeWorktreeBackend": "",
"forWorktreeCreateSyncPruneAndRemoveOperations": "",
"inAdditionToTheRemoteRebaseAboveAlso": "",
"installedAt": "",
"installTheWorktrunkBinaryBelowToEnableThis": "",
"installWorktrunk": "worktrunk 바이너리 설치",
"keepsProgressMovingBySwitchingToFusionApos": "",
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
"maxWorktrees": "",
"offByDefaultOptInWhenEnabledCompleted": "",
"openApprovals": "승인 열기",
"optionalLeaveBlankToAutoResolveFusionWill": "",
"optionalSupports": "",
"pnpmInstallFrozenLockfile": "",
"randomNamesEGSwiftFalcon": "",
"rebaseFromRemoteBeforeMerge": "",
"rebaseRemote": "",
"recycleWorktrees": "",
"selectWorktreesDir": "Worktrees 디렉터리 선택",
"tryAgain": "다시 시도"
"shellCommandToRunInEachNewWorktree": "",
"stopsOnWorktrunkErrorsForExplicitOperatorRecovery": "",
"taskIDEGFN042": "",
"taskTitleEGFixLoginBug": "",
"tryAgain": "다시 시도",
"useGitDefault": "",
"whenEnabledTheMergerFetchesFromTheConfigured": "",
"whenUnsetOnlyAffectsNewlyCreatedWorktrees": "",
"whichRemoteToFetchForThePreMerge": "",
"worktreeInitCommand": "",
"worktreeNamingStyle": "",
"worktrees": "",
"worktreesDirectory": "",
"worktrunk": "",
"worktrunkBinaryPath": "",
"worktrunkFailureBehavior": "",
"worktrunkIntegration": ""
}
},
"setup": {

View File

@@ -142,6 +142,14 @@
"notConfigured": ""
}
},
"settings": {
"nodeRouting": {
"statusConnecting": "",
"statusError": "",
"statusOffline": "",
"statusOnline": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {

View File

@@ -872,6 +872,7 @@
"resetDayMonthly": "每月几号(1–28)",
"resetDayWeekly": "星期几(0=周日)",
"resetting": "正在重置...",
"resizeSidebar": "",
"result": "结果",
"resultCreated_one": "",
"resultCreated_other": "",
@@ -1907,7 +1908,6 @@
"title": "创建房间"
},
"dashboard": {
"brandName": "Fusion",
"initializingDashboard": "初始化仪表板...",
"loadingMessage": "加载 Fusion 仪表板",
"loadingProgress": "仪表板加载进度",
@@ -5458,10 +5458,18 @@
"cancel": "取消",
"save": "保存"
},
"agentPermissions": {
"agentPermissions": "",
"agentProvisioningApprovals": "",
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
},
"appearance": {
"hideAISessionNotificationBanners": "",
"language": "语言",
"languageAuto": "自动",
"languageAutoHint": "跟随浏览器语言",
"suppressTheLdquoNeedsYourInputRdquoBanner": "",
"title": "外观"
},
"auth": {
@@ -5501,10 +5509,51 @@
"title": "认证",
"waitingForLogin": "等待登录…"
},
"authentication": {
"enterAPIKey": "",
"key": ""
},
"backups": {
"02": "",
"03": "",
"agentsOnlyFusionAgentMemory": "",
"allProjectAgents": "",
"and": "",
"backupCreated": "备份创建成功",
"backupDirectory": "",
"backupNow": "立即备份",
"creating": "创建中…"
"backups": "",
"backupS": "",
"backupScheduleCron": "",
"creating": "创建中…",
"cronExpressionForBackupTimingDefault02": "",
"cronExpressionForMemoryBackupTimingDefault0": "",
"currentBackups": "",
"databaseBackups": "",
"directoryForBackupFilesRelativeToProjectRoot": "",
"directoryForMemoryBackupsRelativeToProjectRoot": "",
"enableAutomaticDatabaseBackups": "",
"enableAutomaticMemoryBackups": "",
"fusionBackups": "",
"fusionBackupsMemory": "",
"invalidCronExpressionFormat": "",
"loadingBackupInfo": "",
"memoryBackupDirectory": "",
"memoryBackups": "",
"memoryBackupScheduleCron": "",
"memoryBackupScope": "",
"memoryRetentionCount": "",
"more": "",
"mustBeBetween1And100": "",
"numberOfBackupFilesToKeepOldestAre": "",
"numberOfMemoryBackupsToKeepOldestAre": "",
"pathCannotContainParentDirectoryTraversal": "",
"projectOnlyFusionMemory": "",
"retentionCount": "",
"totalSize": "",
"view": "",
"whenEnabledProjectAndAgentMemoryFilesAre": "",
"whenEnabledTheDatabaseIsBackedUpAutomatically": ""
},
"clearToDefault": "",
"cliAgents": {
@@ -5531,14 +5580,80 @@
"saveFailed": ""
},
"closeModal": "关闭冲突模式",
"commands": {
"buildCommand": "",
"commands": "",
"commandUsedToBuildTheProjectInjectedInto": "",
"commandUsedToRunTestsInjectedIntoGenerated": "",
"eGPnpmBuild": "",
"eGPnpmTest": "",
"testCommand": ""
},
"conflictModalTitle": "解决设置冲突",
"experimental": {
"experimentalFeatures": "",
"experimentalFeaturesAreEarlyCapabilitiesThatAreNot": "",
"featureFlags": ""
},
"footer": {
"help": "帮助",
"version": "版本 {{version}}"
},
"general": {
"25": "",
"200": "",
"3000": "",
"14Days": "",
"30Days": "",
"60Days": "",
"7Days": "",
"90Days": "",
"agentsToRunEachTaskWhenDisabledOnly": "",
"autoCleanupOldChats": "",
"autoPruneOldMail": "",
"bannerFiresWhenTodoCountIsStrictlyGreater": "",
"builtInWorkflows": "",
"capacityRiskBanner": "",
"chatHistory": "",
"chatRooms": "",
"completionDocumentationAutomation": "",
"controlsHowFutureTaskSpecsHandleReleaseNote": "",
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": "",
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "",
"defaultTrackingModeForNewTasks": "",
"deleteChatSessionsAndRoomsThatHaveBeen": "",
"deleteInboxOutboxMessagesOlderThanThisMany": "",
"disabledBuiltInWorkflowsAreHiddenFromWorkflow": "",
"enableSummarizationInProjectModelsToConfigureThatModel": "",
"fN": "",
"general": "",
"gitHubTracking": "",
"hardCapOnTheSynthesizedEarlierRoomContext": "",
"learnMore": "了解更多",
"loweringThisWindowMeansReliabilityMetricsChartsAnd": "",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "",
"numberOfMostRecentChatRoomMessagesKept": "",
"off": "",
"offDefault": "",
"onForNewTasks": "",
"operationalLogRetention": "",
"ownerRepo": "",
"prefixForNewTaskIDsEGKB": "",
"prefixMustBe110UppercaseLetters": "",
"projectDefaultTrackingRepo": "",
"recentVerbatimRoomMessages": "",
"requireChangelogUpdateExistingChangelog": "",
"requireChangesetChangesetMd": "",
"roomCompactionFetchLimit": "",
"roomSummaryMaxCharacters": "",
"searchTheTrackingRepoForLikelyDuplicatesBefore": "",
"settingsSaved": "设置已保存",
"showCapacityRiskBanner": "",
"showQuickChatButton": "",
"showTheFloatingChatButtonInTheDashboard": "",
"taskPrefix": "",
"todoThreshold": "",
"trackingIssuesUseThisTaskAposSTitle": "",
"updateAvailablePrefix": "v{{version}} 可用",
"updateCheckFailed": "",
"updateFailed": "",
@@ -5547,7 +5662,88 @@
"updateSuccess": "",
"updateSuccessToast": "",
"updating": "",
"upToDate": "您已是最新版本 ✓"
"upperBoundOnMessagesFetchedFromTheRoom": "",
"upToDate": "您已是最新版本 ✓",
"useEphemeralTaskWorkerAgents": "",
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
"autoReloadDashboardOnVersionChange": "",
"checkForThe": "",
"checkForUpdatesAutomatically": "",
"cLIBinaryOnPATH": "",
"cLIBySpawning": "",
"controlsHowOftenTheDashboardReFetchesThe": "",
"dailyRecommended": "",
"disableThisIfYourLocalDevProcessIs": "",
"frequency": "",
"general": "",
"globalDefaultTrackingRepo": "",
"leaveBothThinkingTogglesOffToKeepThe": "",
"manualOnlyNeverAutoCheck": "",
"onStartupOncePerServerLaunch": "",
"ownerRepo": "",
"projectsInheritThisValueWhenTheyDoNot": "",
"rowsAndDoesNotAffectAssistantTextOr": "",
"saveAIThinkingForEphemeralTaskWorkerAgents": "",
"saveAIThinkingForPermanentAgents": "",
"saveAIThinkingLogs": "",
"saveToolOutputInAgentLogs": "",
"updates": "",
"weekly": "",
"whenDisabledToolRowsAreStillLoggedBut": "",
"whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "",
"whenEnabledFusionChecksNpmForNewVersions": "",
"whenEnabledTheDashboardProbesForAGlobally": ""
},
"globalModels": {
"allow": "",
"commaSeparatedValuesSentToOpenRouterModelSync": "",
"controlsHowMuchReasoningEffortTheAIModel": "",
"default": "",
"default2": "",
"defaultAIModelUsedForTaskExecutionWhen": "",
"defaultModel": "",
"deny": "",
"fallbackModel": "",
"flowAndPublishesThemUnderTheOpencodeGo": "",
"fusion": "",
"globalBaselineModelsForEachAIRoleProject": "",
"httpsRunfusionAi": "",
"latency": "",
"leaveEmptyToOmitThisHeaderDefaultFusion": "",
"leaveEmptyToOmitThisHeaderDefaultHttps": "",
"modelLanes": "",
"noFallback": "",
"openaiAnthropic": "",
"openRouterAdvanced": "",
"openRouterAllowFallbacks": "",
"openRouterHTTPReferer": "",
"openRouterOutputModalitiesFilter": "",
"openRouterRoutingIgnore": "",
"openRouterRoutingOnly": "",
"openRouterRoutingOrder": "",
"openRouterRoutingSort": "",
"openRouterSupportedParametersFilter": "",
"openRouterXTitle": "",
"price": "",
"providerName": "",
"requireParameters": "",
"startupModelSync": "",
"syncOpencodeGoModelListAtStartup": "",
"syncOpenRouterModelListAtStartup": "",
"text": "",
"thinkingEffort": "",
"throughput": "",
"toolsStructuredOutputs": "",
"usedAutomaticallyIfThePrimaryDefaultModelHits": "",
"useDefault": "",
"whenEnabledStartupFetchesTheLatestAvailableModels": "",
"whenEnabledStartupRefreshesModelsThroughTheLocal": ""
},
"header": {
"discord": "Discord"
@@ -5567,17 +5763,179 @@
"keepRemote": "保留远程",
"loading": "加载中…",
"memory": {
"03": "",
"agentsGetMemorySearchMemoryGetAndMemory": "",
"agentsSearchWithQmdFirstFallBackTo": "",
"automaticallyCompactMemoryWhenItExceedsTheThreshold": "",
"autoSummarizeMemory": "",
"bytesUpdated": "",
"cannotSave": "",
"checkingMemoryWriteAccess": "",
"compacting": "压缩中…",
"compactionThresholdChars": "",
"compactSelectedFile": "压缩选定文件",
"cronExpressionForAutoSummarizeScheduleDefaultDaily": "",
"cronExpressionForDreamProcessing": "",
"dreamCompleted": "梦境处理完成",
"dreaming": "",
"dreamNow": "立即触发梦境",
"dreamSchedule": "",
"enableMemoryTools": "",
"failedToLoadBackendStatus": "",
"for": "",
"installing": "安装中…",
"installQmd": "安装 qmd",
"loadingMemory": "",
"manuallyTriggerDreamProcessingNow": "",
"memory": "",
"memoryCompacted": "记忆文件已压缩",
"memoryFile": "",
"memoryIsConfiguredWithAReadOnlyBackend": "",
"memoryIsCurrentlyDisabledYouCanViewThe": "",
"memoryLivesIn": "",
"memorySaved": "记忆已保存",
"memoryWillBeCompactedWhenItExceedsThis": "",
"noMatchingMemoryFound": "",
"processDreamsFromDailyMemory": "",
"qmd": "",
"qmdIsNotInstalledSearchWillUseLocal": "",
"result": "",
"runsTheSameQmdBackedMemorySearchPath": "",
"saveMemory": "保存记忆",
"scheduleCron": "",
"searchMemoryWithQmd": "",
"testing": "测试中…",
"testRetrieval": "测试检索"
"testRetrieval": "测试检索",
"turnsDailyNotesIntoDREAMSMdAndPromotes": ""
},
"merge": {
"abort": "",
"abortOneAIAttemptRequireManualResolutionIf": "",
"addFusionAsCoAuthorOnCommits": "",
"afterFusionAdvancesTheIntegrationBranchRefThe": "",
"aICorrectiveRoundsBeforeLandingTheBestResult": "",
"aIMerge": "",
"aIMergeDefaultAIMergesInAClean": "",
"aIModeMergesTheTaskBranchIntoAn": "",
"aIOnly": "",
"aIOnlyAIAutoResolveAIRetryNever": "",
"allowAIMergeToSyncADirtyChecked": "",
"alwaysPreserveDirectMergeCommitHistory": "",
"alwaysSquashDirectMerges": "",
"and": "",
"and2": "",
"areListedFirstOrChoose": "",
"autoCompletionMode": "",
"autoDetect": "",
"autoDetectOriginHEADMain": "",
"autoKeepsTodayAposSSquashBehaviorFor": "",
"autoMergeCompletedTasks": "",
"autoMergeConflictRetries": "",
"autoMergeRunsInTheTaskWorktreeBy": "",
"autoMergeWillRunRebaseConflictResolutionAnd": "",
"autoResolveConflictsInLockFilesAndGenerated": "",
"autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive": "",
"autoSyncProjectCheckoutAfterMerge": "",
"block": "",
"blockStrict": "",
"both": "",
"branchName": "",
"checkingForLegacyAutoMergeStamps": "",
"coAuthorEmail": "",
"coAuthorName": "",
"conflictFallbackStrategy": "",
"controlsThePostMergeAuditGate": "",
"controlsWhatHappensAfterATaskReachesIn": "",
"createMonitorAndMergeAGitHubPullRequest": "",
"custom": "",
"dangerousCompatibilityEscapeHatchLeaveOffUnlessYou": "",
"defaultLogsFindingsButAutoCompletesTheMerge": "",
"deterministicLegacyRebaseConflictStrategyAuditPipeline": "",
"directMergeCommitRouting": "",
"directMergeIntoTheCurrentBranch": "",
"emailUsedInThe": "",
"enableTestMode": "",
"fastForwardOfLocalMainFrom": "",
"fastForwardOnlySkipDirtyWorktrees": "",
"finalFallback": "",
"findsInReviewTasksWhoseAutoMergeValue": "",
"flipOverlappingFilesToPreferTheTaskBranch": "",
"forcesAllAILanesToUseTheDeterministic": "",
"fusion": "",
"gitHubAuthentication": "",
"gitHubAuthMode": "",
"gitHubCLIGhAuth": "",
"gitHubPersonalAccessToken": "",
"gitRemoteToPushToEGOrigin": "",
"ignoreOverlapDetectionPreserveLegacyBehavior": "",
"includeTaskIDInCommitScope": "",
"insteadOf": "",
"integrationBranch": "",
"integrationWorktree": "",
"inYourProjectRootWillShowTheNew": "",
"isTheLegacyBehavior": "",
"isTheStricterOptInModeThatRefuses": "",
"legacy": "",
"legacy2": "",
"legacyAutoMergeStamp": "",
"legacyAutoMergeStampCleanup": "",
"legacyIntegrationBranchMode": "",
"maxAIReviewPasses": "",
"merge": "",
"moreDetails": "",
"nameUsedInThe": "",
"noLegacyAutoMergeStampsToCleanUp": "",
"noreplyRunfusionAi": "",
"off": "",
"offLeaveTheProjectRootStaleLegacyBehavior": "",
"offSkipAudit": "",
"optionsStartWithABestEffort": "",
"origin": "",
"personalAccessToken": "",
"pickALocalBranchFromTheDropdownCommon": "",
"positiveIntegerRetryCapForAutoMergeConflict": "",
"postMergeAuditMode": "",
"pushRemote": "",
"pushToRemoteAfterMerge": "",
"readyToCleanUp": "",
"retriesTheAIAgentRatherThanAutoPicking": "",
"reuseTaskWorktreeDefault": "",
"skipsTheAuditEntirelySwitchingToOffIs": "",
"smart": "",
"smartConflictResolution": "",
"smartPreferMain": "",
"smartPreferMainOnFallbackFetchFfOrigin": "",
"smartPreferMainOverlapGuard": "",
"smartPreferTask": "",
"smartPreferTaskOnFallbackFetchFfOrigin": "",
"snapsCleanlyWhenTheWorktreeHasNoEdits": "",
"snapshotsRealLocalEditsAsAPatchAgainst": "",
"soAFreshlyPushedSiblingCommitDoesntGet": "",
"soMainWinsProtectsJustMergedSiblingWork": "",
"soTheTaskBranchWinsFastButCan": "",
"stashFastForwardDefaultPreserveLocalEdits": "",
"stopsAfterTheFirstAIAttemptAndWaits": "",
"symbolicRefFallback": "",
"theCanonicalBranchFusionMergesTasksIntoAnd": "",
"theLegacyMergeSettingsBelowDoNotApply": "",
"toResolveViaTheStandardCascade": "",
"toTypeABranchThatDoesnAposT": "",
"trailer": "",
"trailerCreditingFusionRecognizedByGitHubForShared": "",
"useDropdown": "",
"useProjectRootLegacy": "",
"uses": "",
"valuesFromOlderSettingsAreMigratedAutomatically": "",
"warn": "",
"warnDefaultLogFindingsContinue": "",
"warnOnlyKeepLegacyMainWinsFallback": "",
"whenDisabledMergeCommitMessagesOmitTheTask": "",
"whenEnabledCommitsMadeByFusionKeepYour": "",
"whenEnabledLockFilesPackageLockJsonPnpm": "",
"whenEnabledLockFilesPackageLockJsonPnpm2": "",
"whenEnabledTasksThatPassReviewAreAutomatically": "",
"whenEnabledTheMergedResultIsAutomaticallyPushed": "",
"whenUsingSmartPreferMainAutomaticallyPreferThe": ""
},
"mergeManually": "手动合并",
"mobileNav": {
@@ -5610,38 +5968,314 @@
"project": "特定于此项目"
}
},
"nodeRouting": {
"blockExecution": "",
"configureHowTasksAreRoutedToExecutionNodes": "",
"defaultExecutionNode": "",
"fallBackToLocal": "",
"localExecutionNoDefaultNode": "",
"nodeRouting": "",
"selectedNode": "",
"theseSettingsApplyAtTheProjectLevel": "",
"unavailableNodePolicy": "",
"usedWhenATaskHasNoNodeOverride": ""
},
"nodeSync": {
"alwaysAsk": "",
"automaticallySynchronizeSettingsBetweenThisNodeAndConnected": "",
"conflictResolution": "",
"enableAutomaticSettingsSync": "",
"every15Minutes": "",
"every1Hour": "",
"every30Minutes": "",
"every5Minutes": "",
"includeAPIKeysAndOAuthTokensInSync": "",
"keepLocal": "",
"keepRemote": "",
"lastWriteWins": "",
"nodeSync": "",
"syncInterval": "",
"syncModelAuthCredentials": "",
"workflowSettingsNotSynced": ""
},
"notifications": {
"accessTokenOptional": "",
"advanced": "",
"allFailuresLegacy": "",
"baseURLForDeepLinksInNotificationsWhen": "",
"customNtfyServerURLOptional": "",
"dashboardHostname": "",
"discord": "",
"enable": "",
"failureNotificationDelayMs": "",
"failureNotificationMode": "",
"format": "",
"general": "",
"generic": "",
"howLongAFailureMustPersistBeforeA": "",
"httpLocalhost3000": "",
"httpsHooksExampleCom": "",
"httpsNtfySh": "",
"learnMoreAboutNtfySh": "",
"leaveBlankToKeepTheDefaultServerHttps": "",
"leaveBlankToPublishWithoutAuthenticationWhenSet": "",
"messageInbox": "",
"mustBeAValidURLStartingWithHttp": "",
"myTopicName": "",
"notifications": "",
"notifyOnEvents": "",
"ntfy": "",
"ntfyTopic": "",
"roomReply": "",
"sending": "发送中…",
"slack": "",
"stickyFailuresOnlyDefault": "",
"stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits": "",
"terminalFailuresOnlySuppressAutoRetried": "",
"testMessageInbox": "测试消息收件箱",
"testNotification": "测试通知",
"testRoomReply": "测试房间回复"
"testRoomReply": "测试房间回复",
"tk": "",
"topicMustBe164AlphanumericHyphenOr": "",
"webhook": "",
"webhookNotifications": "",
"webhookURL": "",
"yourNtfyShTopicName164Alphanumeric": ""
},
"plugins": {
"fusionPlugins": "",
"piExtensions": "",
"pluginManagerType": "",
"plugins": ""
},
"projectModels": {
"addPreset": "",
"advancedWorkflowPolicy": "",
"aIMergeCommitSummaries": "",
"aITitleAndGitCommitMessageSummarization": "",
"automaticallyCompactContextWhenApproachingThisTokenCount": "",
"autoSelectPresetBasedOnTaskSize": "",
"autoSummarizeLongDescriptionsAsTitles": "",
"configuredPresets": "",
"configuresTheModelUsedForTwoShortSummary": "",
"defaultWorkflowModelLaneActions": "",
"defaultWorkflowModelLanes": "",
"delete": "",
"edit": "",
"executorModel": "",
"fallsBackTo": "",
"loadingAvailableModels": "",
"loadingWorkflowModelLanes": "",
"modelLanes": "",
"modelPresets": "",
"name": "",
"noCap": "",
"noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow": "",
"noModelsAvailableConfigureAuthenticationFirst": "",
"noModelsAvailableConfigureAuthenticationFirst2": "",
"noPreset": "",
"noPresetsConfiguredYet": "",
"openAProjectToEditWorkflowModelLanes": "",
"overrideGlobalModelSettingsAtTheProjectLevel": "",
"presetEditor": "",
"reset": "",
"resetToDefaultNoCap": "",
"resetToInheritFromGlobal": "",
"resetToInheritFromWorkflow": "",
"reviewerModel": "",
"theseProjectOverridesApplyToTheActiveDefault": "",
"tokenCap": "",
"useDefault": "",
"useWorkflowDefault": "",
"whenEnabledMergeCommitMessagesIncludeAnAI": "",
"whenEnabledTasksCreatedWithoutATitleBut": ""
},
"remote": {
"acceptRoutes": "",
"advancedNamedTunnel": "",
"advancedSettings": "",
"authenticatedURL": "",
"authLinks": "",
"authLinkTokenType": "",
"automaticallyRestoreTunnelOnStartupIfItWas": "",
"cloudflare": "",
"cloudflaredInstalled": "cloudflared 安装成功",
"cloudflaredIsInstalled": "",
"cloudflaredIsNotInstalled": "",
"cloudflaredMustBeInstalledToStartTheTunnel": "",
"enableShortLivedTokens": "",
"expiresAt": "",
"external": "",
"externalTunnelQRCode": "",
"generateQR": "",
"generateShortLivedToken": "",
"httpsYourDomainExample": "",
"ifHomebrewIsUnavailable": "",
"ingressURL": "",
"installCloudflared": "",
"installing": "",
"lastShortLivedTokenExpiresAt": "",
"manualInstall": "",
"ms": "",
"namedTunnelModeEnabled": "",
"noExpiry": "",
"persistentToken": "",
"persistentTokenRegenerated": "持久令牌已重新生成",
"qRSVGMarkup": "",
"regeneratePersistentToken": "",
"rememberLastRunningState": "",
"remoteAccess": "",
"remoteAccessCode": "",
"remoteAccessQRCode": "",
"remoteProvider": "",
"restarting": "重启中…",
"scanThisQRCodeOnYourPhone": "",
"scanToConnect": "",
"scanToOpen": "",
"selectAProviderAboveToConfigureRemoteAccess": "",
"shortLivedToken": "",
"shortLivedTokenGenerated": "短期令牌已生成",
"shortLivedTTLMs": "",
"showURL": "",
"startFresh": "重新启动",
"starting": "启动中…",
"startTunnel": "启动隧道",
"stopping": "停止中…",
"stopTunnel": "停止隧道",
"tailnetURL": "",
"tailscale": "",
"tailscaleFunnelWillExposeThisDashboardOnYour": "",
"tokenType": "",
"tunnelDetected": "",
"tunnelName": "",
"tunnelRestarted": "远程隧道已重启",
"tunnelStarted": "远程隧道已启动",
"tunnelStopped": "远程隧道已停止",
"useExisting": "使用现有"
"tunnelToken": "",
"uRLAndQRGenerationUseTheSelectedToken": "",
"uRLNoHostnameOrPortConfigurationNeeded": "",
"useExisting": "使用现有",
"usingQuickTunnel": ""
},
"researchGlobal": {
"advancedExternalSearchProviders": "",
"alwaysOn": "",
"brave": "",
"builtInUsesAgentWebTools": "",
"configureBraveTavilyAndGoogleAPIKeysIn": "",
"customSearchEngineId": "",
"defaultMaxConcurrentRuns": "",
"defaultMaxDurationMs": "",
"defaultMaxSourcesPerRun": "",
"enabledSources": "",
"gitHub": "",
"googleCustomSearch": "",
"googleSearchCX": "",
"httpsSearxExampleCom": "",
"localDocs": "",
"maxSynthesisRounds": "",
"missingCredentialsForTheSelectedResearchProvider": "",
"openAuthentication": "",
"openAuthenticationSettings": "",
"requestTimeoutMs": "",
"researchDefaults": "",
"searchesAndFetchesUseTheAgentsNativeWebSearch": "",
"searchProvider": "",
"searXNG": "",
"searXNGURL": "",
"tavily": "",
"webSearch": ""
},
"researchProject": {
"alwaysOn": "",
"enabledSources": "",
"enableResearchInThisProject": "",
"github": "",
"llmSynthesis": "",
"localDocs": "",
"maxConcurrentRuns": "",
"maxDurationMs": "",
"maxSourcesPerRun": "",
"pageFetch": "",
"projectResearchSettings": "",
"requestTimeoutMs": "",
"webSearch": "",
"webSearchIsAlwaysEnabledConfigureTheSearch": ""
},
"resolveAllLocal": "全部解决:保留本地",
"resolveAllRemote": "全部解决:保留远程",
"resolvedSuccess": "设置冲突已成功解决",
"resolveFailed": "无法解决冲突",
"resolving": "解决中...",
"runtimesRuntimes": {
"hermesRuntime": "",
"openClawRuntime": "",
"paperclipRuntime": ""
},
"scheduledEvals": {
"autoCreateTasks": "",
"disabled": "",
"enableScheduledEvalRunsForThisProject": "",
"evaluatorModel": "",
"evaluatorProvider": "",
"followUpPolicy": "",
"gpt5": "",
"intervalMs": "",
"leaveProviderAndModelBlankToInheritThe": "",
"openai": "",
"retentionDays": "",
"scheduledEvals": "",
"suggestOnly": ""
},
"scheduling": {
"addIgnoredPath": "",
"archiveAgentLog": "",
"archiveCompletedTasksAfterDays": "",
"backlogNoTaskAutoClaimIsExecutorOnly": "",
"browse": "",
"closeParenPeriod": "",
"compactModeKeepsArchiveSizeLowWhilePreserving": "",
"compactSummaryAndRecentEntries": "",
"completedTasksOlderThanTheThresholdAreMoved": "",
"docs": "",
"doNotArchiveAgentLogs": "",
"enableAutomaticTaskArchiving": "",
"enablePlanStalenessEnforcement": "",
"escalateHighFanOutBlockersOnlyAfterThey": "",
"fullAgentLog": "",
"globalMaxConcurrent": "",
"heartbeatScopeDiscipline": "",
"ignoredOverlapPaths": "",
"letEngineerAgentsAutoClaimBacklogTasks": "",
"lite": "",
"maxConcurrentTasks": "",
"maximumAgeInHoursBeforeAPlanIs": "",
"maximumConcurrentAgentsAcrossAllProjects": "",
"maximumConcurrentPlanningAgents": "",
"maximumStuckDetectorRetriesBeforeATaskIs": "",
"maxStuckRetries": "",
"maxTriageConcurrent": "",
"numberOfDaysATaskCanStayIn": "",
"off": "",
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
"or": "",
"pollIntervalMs": "",
"preserveStepProgressOnStuckTaskRequeue": "",
"remove": "",
"scheduling": "",
"selectCurrentDir": "选择当前目录",
"selectIgnoredOverlapPath": "选择忽略的重叠路径"
"selectIgnoredOverlapPath": "选择忽略的重叠路径",
"serializeTasksWithOverlappingFiles": "",
"staleHighFanOutEscalationHours": "",
"staleSpecThresholdHours": "",
"stepExecution": "",
"strictCoordinationFocusedHigherPerTickTokensLite": "",
"strictDefault": "",
"stuckTaskTimeoutMinutes": "",
"timeoutInMinutesForDetectingStuckTasksWhen": "",
"whenEnabledTasksThatModifyTheSameFiles": "",
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": ""
},
"scope": {
"globalBanner": "这些设置在所有 Fusion 项目中共享。",
@@ -5649,11 +6283,59 @@
},
"title": "设置",
"worktrees": {
"allowSilentSiblingBranchRenameDuringExecutorConflicts": "",
"alsoRebaseOntoLocalDefaultBranchHEAD": "",
"and": "",
"andCanHidePriorCommitsFromTheDefault": "",
"autoDetectFusionBinWorktrunkOrPATH": "",
"awaitingApproval": "等待审批——打开审批以继续。",
"branchCollisionSilentlyForksWorkOntoSiblingBranches": "",
"browse": "",
"browseWorktreesDirectory": "",
"closeParenPeriod": "",
"defaultsTo": "",
"defaultsToWorktreesLeaveEmptyUnlessOverriding": "",
"disabledByDefaultOptInWhenEnabledFusion": "",
"discouragedThisRestoresTheLegacyBehaviorWhereA": "",
"enableWorktrunkAndRequestApprovalToInstallThe": "",
"enableWorktrunkIntegration": "",
"failAndPauseTheTaskDefault": "",
"fallBackToFusionsNativeWorktreeBackend": "",
"forWorktreeCreateSyncPruneAndRemoveOperations": "",
"inAdditionToTheRemoteRebaseAboveAlso": "",
"installedAt": "",
"installTheWorktrunkBinaryBelowToEnableThis": "",
"installWorktrunk": "安装 worktrunk 二进制文件",
"keepsProgressMovingBySwitchingToFusionApos": "",
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
"maxWorktrees": "",
"offByDefaultOptInWhenEnabledCompleted": "",
"openApprovals": "打开审批",
"optionalLeaveBlankToAutoResolveFusionWill": "",
"optionalSupports": "",
"pnpmInstallFrozenLockfile": "",
"randomNamesEGSwiftFalcon": "",
"rebaseFromRemoteBeforeMerge": "",
"rebaseRemote": "",
"recycleWorktrees": "",
"selectWorktreesDir": "选择工作树目录",
"tryAgain": "重试"
"shellCommandToRunInEachNewWorktree": "",
"stopsOnWorktrunkErrorsForExplicitOperatorRecovery": "",
"taskIDEGFN042": "",
"taskTitleEGFixLoginBug": "",
"tryAgain": "重试",
"useGitDefault": "",
"whenEnabledTheMergerFetchesFromTheConfigured": "",
"whenUnsetOnlyAffectsNewlyCreatedWorktrees": "",
"whichRemoteToFetchForThePreMerge": "",
"worktreeInitCommand": "",
"worktreeNamingStyle": "",
"worktrees": "",
"worktreesDirectory": "",
"worktrunk": "",
"worktrunkBinaryPath": "",
"worktrunkFailureBehavior": "",
"worktrunkIntegration": ""
}
},
"setup": {

View File

@@ -142,6 +142,14 @@
"notConfigured": ""
}
},
"settings": {
"nodeRouting": {
"statusConnecting": "",
"statusError": "",
"statusOffline": "",
"statusOnline": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {

View File

@@ -872,6 +872,7 @@
"resetDayMonthly": "每月幾號(1–28)",
"resetDayWeekly": "星期幾(0=週日)",
"resetting": "正在重置...",
"resizeSidebar": "",
"result": "結果",
"resultCreated_one": "",
"resultCreated_other": "",
@@ -1907,7 +1908,6 @@
"title": "建立房間"
},
"dashboard": {
"brandName": "Fusion",
"initializingDashboard": "初始化儀表板...",
"loadingMessage": "載入 Fusion 儀表板",
"loadingProgress": "儀表板加載進度",
@@ -5458,10 +5458,18 @@
"cancel": "取消",
"save": "儲存"
},
"agentPermissions": {
"agentPermissions": "",
"agentProvisioningApprovals": "",
"configureProjectLevelApprovalBehaviorForDurableProvisioning": "",
"perAgentSettingsOverrideProjectDefaultsEachCategory": ""
},
"appearance": {
"hideAISessionNotificationBanners": "",
"language": "語言",
"languageAuto": "自動",
"languageAutoHint": "跟隨瀏覽器語言",
"suppressTheLdquoNeedsYourInputRdquoBanner": "",
"title": "外觀"
},
"auth": {
@@ -5501,10 +5509,51 @@
"title": "驗證",
"waitingForLogin": "等待登入…"
},
"authentication": {
"enterAPIKey": "",
"key": ""
},
"backups": {
"02": "",
"03": "",
"agentsOnlyFusionAgentMemory": "",
"allProjectAgents": "",
"and": "",
"backupCreated": "備份建立成功",
"backupDirectory": "",
"backupNow": "立即備份",
"creating": "建立中…"
"backups": "",
"backupS": "",
"backupScheduleCron": "",
"creating": "建立中…",
"cronExpressionForBackupTimingDefault02": "",
"cronExpressionForMemoryBackupTimingDefault0": "",
"currentBackups": "",
"databaseBackups": "",
"directoryForBackupFilesRelativeToProjectRoot": "",
"directoryForMemoryBackupsRelativeToProjectRoot": "",
"enableAutomaticDatabaseBackups": "",
"enableAutomaticMemoryBackups": "",
"fusionBackups": "",
"fusionBackupsMemory": "",
"invalidCronExpressionFormat": "",
"loadingBackupInfo": "",
"memoryBackupDirectory": "",
"memoryBackups": "",
"memoryBackupScheduleCron": "",
"memoryBackupScope": "",
"memoryRetentionCount": "",
"more": "",
"mustBeBetween1And100": "",
"numberOfBackupFilesToKeepOldestAre": "",
"numberOfMemoryBackupsToKeepOldestAre": "",
"pathCannotContainParentDirectoryTraversal": "",
"projectOnlyFusionMemory": "",
"retentionCount": "",
"totalSize": "",
"view": "",
"whenEnabledProjectAndAgentMemoryFilesAre": "",
"whenEnabledTheDatabaseIsBackedUpAutomatically": ""
},
"clearToDefault": "",
"cliAgents": {
@@ -5531,14 +5580,80 @@
"saveFailed": ""
},
"closeModal": "關閉衝突模式",
"commands": {
"buildCommand": "",
"commands": "",
"commandUsedToBuildTheProjectInjectedInto": "",
"commandUsedToRunTestsInjectedIntoGenerated": "",
"eGPnpmBuild": "",
"eGPnpmTest": "",
"testCommand": ""
},
"conflictModalTitle": "解決設定衝突",
"experimental": {
"experimentalFeatures": "",
"experimentalFeaturesAreEarlyCapabilitiesThatAreNot": "",
"featureFlags": ""
},
"footer": {
"help": "說明",
"version": "版本 {{version}}"
},
"general": {
"25": "",
"200": "",
"3000": "",
"14Days": "",
"30Days": "",
"60Days": "",
"7Days": "",
"90Days": "",
"agentsToRunEachTaskWhenDisabledOnly": "",
"autoCleanupOldChats": "",
"autoPruneOldMail": "",
"bannerFiresWhenTodoCountIsStrictlyGreater": "",
"builtInWorkflows": "",
"capacityRiskBanner": "",
"chatHistory": "",
"chatRooms": "",
"completionDocumentationAutomation": "",
"controlsHowFutureTaskSpecsHandleReleaseNote": "",
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": "",
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "",
"defaultTrackingModeForNewTasks": "",
"deleteChatSessionsAndRoomsThatHaveBeen": "",
"deleteInboxOutboxMessagesOlderThanThisMany": "",
"disabledBuiltInWorkflowsAreHiddenFromWorkflow": "",
"enableSummarizationInProjectModelsToConfigureThatModel": "",
"fN": "",
"general": "",
"gitHubTracking": "",
"hardCapOnTheSynthesizedEarlierRoomContext": "",
"learnMore": "了解更多",
"loweringThisWindowMeansReliabilityMetricsChartsAnd": "",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "",
"numberOfMostRecentChatRoomMessagesKept": "",
"off": "",
"offDefault": "",
"onForNewTasks": "",
"operationalLogRetention": "",
"ownerRepo": "",
"prefixForNewTaskIDsEGKB": "",
"prefixMustBe110UppercaseLetters": "",
"projectDefaultTrackingRepo": "",
"recentVerbatimRoomMessages": "",
"requireChangelogUpdateExistingChangelog": "",
"requireChangesetChangesetMd": "",
"roomCompactionFetchLimit": "",
"roomSummaryMaxCharacters": "",
"searchTheTrackingRepoForLikelyDuplicatesBefore": "",
"settingsSaved": "設定已儲存",
"showCapacityRiskBanner": "",
"showQuickChatButton": "",
"showTheFloatingChatButtonInTheDashboard": "",
"taskPrefix": "",
"todoThreshold": "",
"trackingIssuesUseThisTaskAposSTitle": "",
"updateAvailablePrefix": "v{{version}} 可用",
"updateCheckFailed": "",
"updateFailed": "",
@@ -5547,7 +5662,88 @@
"updateSuccess": "",
"updateSuccessToast": "",
"updating": "",
"upToDate": "您已是最新版本 ✓"
"upperBoundOnMessagesFetchedFromTheRoom": "",
"upToDate": "您已是最新版本 ✓",
"useEphemeralTaskWorkerAgents": "",
"warnOnTheBoardWhenTodoWorkExceeds": "",
"whenEnabledDefaultFusionSpawnsShortLived": "",
"whenEnabledFusionChecksOpenAndClosedIssues": "",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",
"autoReloadDashboardOnVersionChange": "",
"checkForThe": "",
"checkForUpdatesAutomatically": "",
"cLIBinaryOnPATH": "",
"cLIBySpawning": "",
"controlsHowOftenTheDashboardReFetchesThe": "",
"dailyRecommended": "",
"disableThisIfYourLocalDevProcessIs": "",
"frequency": "",
"general": "",
"globalDefaultTrackingRepo": "",
"leaveBothThinkingTogglesOffToKeepThe": "",
"manualOnlyNeverAutoCheck": "",
"onStartupOncePerServerLaunch": "",
"ownerRepo": "",
"projectsInheritThisValueWhenTheyDoNot": "",
"rowsAndDoesNotAffectAssistantTextOr": "",
"saveAIThinkingForEphemeralTaskWorkerAgents": "",
"saveAIThinkingForPermanentAgents": "",
"saveAIThinkingLogs": "",
"saveToolOutputInAgentLogs": "",
"updates": "",
"weekly": "",
"whenDisabledToolRowsAreStillLoggedBut": "",
"whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": "",
"whenEnabledFusionChecksNpmForNewVersions": "",
"whenEnabledTheDashboardProbesForAGlobally": ""
},
"globalModels": {
"allow": "",
"commaSeparatedValuesSentToOpenRouterModelSync": "",
"controlsHowMuchReasoningEffortTheAIModel": "",
"default": "",
"default2": "",
"defaultAIModelUsedForTaskExecutionWhen": "",
"defaultModel": "",
"deny": "",
"fallbackModel": "",
"flowAndPublishesThemUnderTheOpencodeGo": "",
"fusion": "",
"globalBaselineModelsForEachAIRoleProject": "",
"httpsRunfusionAi": "",
"latency": "",
"leaveEmptyToOmitThisHeaderDefaultFusion": "",
"leaveEmptyToOmitThisHeaderDefaultHttps": "",
"modelLanes": "",
"noFallback": "",
"openaiAnthropic": "",
"openRouterAdvanced": "",
"openRouterAllowFallbacks": "",
"openRouterHTTPReferer": "",
"openRouterOutputModalitiesFilter": "",
"openRouterRoutingIgnore": "",
"openRouterRoutingOnly": "",
"openRouterRoutingOrder": "",
"openRouterRoutingSort": "",
"openRouterSupportedParametersFilter": "",
"openRouterXTitle": "",
"price": "",
"providerName": "",
"requireParameters": "",
"startupModelSync": "",
"syncOpencodeGoModelListAtStartup": "",
"syncOpenRouterModelListAtStartup": "",
"text": "",
"thinkingEffort": "",
"throughput": "",
"toolsStructuredOutputs": "",
"usedAutomaticallyIfThePrimaryDefaultModelHits": "",
"useDefault": "",
"whenEnabledStartupFetchesTheLatestAvailableModels": "",
"whenEnabledStartupRefreshesModelsThroughTheLocal": ""
},
"header": {
"discord": "Discord"
@@ -5567,17 +5763,179 @@
"keepRemote": "保留遠端",
"loading": "載入中…",
"memory": {
"03": "",
"agentsGetMemorySearchMemoryGetAndMemory": "",
"agentsSearchWithQmdFirstFallBackTo": "",
"automaticallyCompactMemoryWhenItExceedsTheThreshold": "",
"autoSummarizeMemory": "",
"bytesUpdated": "",
"cannotSave": "",
"checkingMemoryWriteAccess": "",
"compacting": "壓縮中…",
"compactionThresholdChars": "",
"compactSelectedFile": "壓縮選定檔案",
"cronExpressionForAutoSummarizeScheduleDefaultDaily": "",
"cronExpressionForDreamProcessing": "",
"dreamCompleted": "夢境處理完成",
"dreaming": "",
"dreamNow": "立即觸發夢境",
"dreamSchedule": "",
"enableMemoryTools": "",
"failedToLoadBackendStatus": "",
"for": "",
"installing": "安裝中…",
"installQmd": "安裝 qmd",
"loadingMemory": "",
"manuallyTriggerDreamProcessingNow": "",
"memory": "",
"memoryCompacted": "記憶檔案已壓縮",
"memoryFile": "",
"memoryIsConfiguredWithAReadOnlyBackend": "",
"memoryIsCurrentlyDisabledYouCanViewThe": "",
"memoryLivesIn": "",
"memorySaved": "記憶已儲存",
"memoryWillBeCompactedWhenItExceedsThis": "",
"noMatchingMemoryFound": "",
"processDreamsFromDailyMemory": "",
"qmd": "",
"qmdIsNotInstalledSearchWillUseLocal": "",
"result": "",
"runsTheSameQmdBackedMemorySearchPath": "",
"saveMemory": "儲存記憶",
"scheduleCron": "",
"searchMemoryWithQmd": "",
"testing": "測試中…",
"testRetrieval": "測試擷取"
"testRetrieval": "測試擷取",
"turnsDailyNotesIntoDREAMSMdAndPromotes": ""
},
"merge": {
"abort": "",
"abortOneAIAttemptRequireManualResolutionIf": "",
"addFusionAsCoAuthorOnCommits": "",
"afterFusionAdvancesTheIntegrationBranchRefThe": "",
"aICorrectiveRoundsBeforeLandingTheBestResult": "",
"aIMerge": "",
"aIMergeDefaultAIMergesInAClean": "",
"aIModeMergesTheTaskBranchIntoAn": "",
"aIOnly": "",
"aIOnlyAIAutoResolveAIRetryNever": "",
"allowAIMergeToSyncADirtyChecked": "",
"alwaysPreserveDirectMergeCommitHistory": "",
"alwaysSquashDirectMerges": "",
"and": "",
"and2": "",
"areListedFirstOrChoose": "",
"autoCompletionMode": "",
"autoDetect": "",
"autoDetectOriginHEADMain": "",
"autoKeepsTodayAposSSquashBehaviorFor": "",
"autoMergeCompletedTasks": "",
"autoMergeConflictRetries": "",
"autoMergeRunsInTheTaskWorktreeBy": "",
"autoMergeWillRunRebaseConflictResolutionAnd": "",
"autoResolveConflictsInLockFilesAndGenerated": "",
"autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive": "",
"autoSyncProjectCheckoutAfterMerge": "",
"block": "",
"blockStrict": "",
"both": "",
"branchName": "",
"checkingForLegacyAutoMergeStamps": "",
"coAuthorEmail": "",
"coAuthorName": "",
"conflictFallbackStrategy": "",
"controlsThePostMergeAuditGate": "",
"controlsWhatHappensAfterATaskReachesIn": "",
"createMonitorAndMergeAGitHubPullRequest": "",
"custom": "",
"dangerousCompatibilityEscapeHatchLeaveOffUnlessYou": "",
"defaultLogsFindingsButAutoCompletesTheMerge": "",
"deterministicLegacyRebaseConflictStrategyAuditPipeline": "",
"directMergeCommitRouting": "",
"directMergeIntoTheCurrentBranch": "",
"emailUsedInThe": "",
"enableTestMode": "",
"fastForwardOfLocalMainFrom": "",
"fastForwardOnlySkipDirtyWorktrees": "",
"finalFallback": "",
"findsInReviewTasksWhoseAutoMergeValue": "",
"flipOverlappingFilesToPreferTheTaskBranch": "",
"forcesAllAILanesToUseTheDeterministic": "",
"fusion": "",
"gitHubAuthentication": "",
"gitHubAuthMode": "",
"gitHubCLIGhAuth": "",
"gitHubPersonalAccessToken": "",
"gitRemoteToPushToEGOrigin": "",
"ignoreOverlapDetectionPreserveLegacyBehavior": "",
"includeTaskIDInCommitScope": "",
"insteadOf": "",
"integrationBranch": "",
"integrationWorktree": "",
"inYourProjectRootWillShowTheNew": "",
"isTheLegacyBehavior": "",
"isTheStricterOptInModeThatRefuses": "",
"legacy": "",
"legacy2": "",
"legacyAutoMergeStamp": "",
"legacyAutoMergeStampCleanup": "",
"legacyIntegrationBranchMode": "",
"maxAIReviewPasses": "",
"merge": "",
"moreDetails": "",
"nameUsedInThe": "",
"noLegacyAutoMergeStampsToCleanUp": "",
"noreplyRunfusionAi": "",
"off": "",
"offLeaveTheProjectRootStaleLegacyBehavior": "",
"offSkipAudit": "",
"optionsStartWithABestEffort": "",
"origin": "",
"personalAccessToken": "",
"pickALocalBranchFromTheDropdownCommon": "",
"positiveIntegerRetryCapForAutoMergeConflict": "",
"postMergeAuditMode": "",
"pushRemote": "",
"pushToRemoteAfterMerge": "",
"readyToCleanUp": "",
"retriesTheAIAgentRatherThanAutoPicking": "",
"reuseTaskWorktreeDefault": "",
"skipsTheAuditEntirelySwitchingToOffIs": "",
"smart": "",
"smartConflictResolution": "",
"smartPreferMain": "",
"smartPreferMainOnFallbackFetchFfOrigin": "",
"smartPreferMainOverlapGuard": "",
"smartPreferTask": "",
"smartPreferTaskOnFallbackFetchFfOrigin": "",
"snapsCleanlyWhenTheWorktreeHasNoEdits": "",
"snapshotsRealLocalEditsAsAPatchAgainst": "",
"soAFreshlyPushedSiblingCommitDoesntGet": "",
"soMainWinsProtectsJustMergedSiblingWork": "",
"soTheTaskBranchWinsFastButCan": "",
"stashFastForwardDefaultPreserveLocalEdits": "",
"stopsAfterTheFirstAIAttemptAndWaits": "",
"symbolicRefFallback": "",
"theCanonicalBranchFusionMergesTasksIntoAnd": "",
"theLegacyMergeSettingsBelowDoNotApply": "",
"toResolveViaTheStandardCascade": "",
"toTypeABranchThatDoesnAposT": "",
"trailer": "",
"trailerCreditingFusionRecognizedByGitHubForShared": "",
"useDropdown": "",
"useProjectRootLegacy": "",
"uses": "",
"valuesFromOlderSettingsAreMigratedAutomatically": "",
"warn": "",
"warnDefaultLogFindingsContinue": "",
"warnOnlyKeepLegacyMainWinsFallback": "",
"whenDisabledMergeCommitMessagesOmitTheTask": "",
"whenEnabledCommitsMadeByFusionKeepYour": "",
"whenEnabledLockFilesPackageLockJsonPnpm": "",
"whenEnabledLockFilesPackageLockJsonPnpm2": "",
"whenEnabledTasksThatPassReviewAreAutomatically": "",
"whenEnabledTheMergedResultIsAutomaticallyPushed": "",
"whenUsingSmartPreferMainAutomaticallyPreferThe": ""
},
"mergeManually": "手動合併",
"mobileNav": {
@@ -5610,38 +5968,314 @@
"project": "特定於此專案"
}
},
"nodeRouting": {
"blockExecution": "",
"configureHowTasksAreRoutedToExecutionNodes": "",
"defaultExecutionNode": "",
"fallBackToLocal": "",
"localExecutionNoDefaultNode": "",
"nodeRouting": "",
"selectedNode": "",
"theseSettingsApplyAtTheProjectLevel": "",
"unavailableNodePolicy": "",
"usedWhenATaskHasNoNodeOverride": ""
},
"nodeSync": {
"alwaysAsk": "",
"automaticallySynchronizeSettingsBetweenThisNodeAndConnected": "",
"conflictResolution": "",
"enableAutomaticSettingsSync": "",
"every15Minutes": "",
"every1Hour": "",
"every30Minutes": "",
"every5Minutes": "",
"includeAPIKeysAndOAuthTokensInSync": "",
"keepLocal": "",
"keepRemote": "",
"lastWriteWins": "",
"nodeSync": "",
"syncInterval": "",
"syncModelAuthCredentials": "",
"workflowSettingsNotSynced": ""
},
"notifications": {
"accessTokenOptional": "",
"advanced": "",
"allFailuresLegacy": "",
"baseURLForDeepLinksInNotificationsWhen": "",
"customNtfyServerURLOptional": "",
"dashboardHostname": "",
"discord": "",
"enable": "",
"failureNotificationDelayMs": "",
"failureNotificationMode": "",
"format": "",
"general": "",
"generic": "",
"howLongAFailureMustPersistBeforeA": "",
"httpLocalhost3000": "",
"httpsHooksExampleCom": "",
"httpsNtfySh": "",
"learnMoreAboutNtfySh": "",
"leaveBlankToKeepTheDefaultServerHttps": "",
"leaveBlankToPublishWithoutAuthenticationWhenSet": "",
"messageInbox": "",
"mustBeAValidURLStartingWithHttp": "",
"myTopicName": "",
"notifications": "",
"notifyOnEvents": "",
"ntfy": "",
"ntfyTopic": "",
"roomReply": "",
"sending": "發送中…",
"slack": "",
"stickyFailuresOnlyDefault": "",
"stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits": "",
"terminalFailuresOnlySuppressAutoRetried": "",
"testMessageInbox": "測試訊息收件匣",
"testNotification": "測試通知",
"testRoomReply": "測試房間回覆"
"testRoomReply": "測試房間回覆",
"tk": "",
"topicMustBe164AlphanumericHyphenOr": "",
"webhook": "",
"webhookNotifications": "",
"webhookURL": "",
"yourNtfyShTopicName164Alphanumeric": ""
},
"plugins": {
"fusionPlugins": "",
"piExtensions": "",
"pluginManagerType": "",
"plugins": ""
},
"projectModels": {
"addPreset": "",
"advancedWorkflowPolicy": "",
"aIMergeCommitSummaries": "",
"aITitleAndGitCommitMessageSummarization": "",
"automaticallyCompactContextWhenApproachingThisTokenCount": "",
"autoSelectPresetBasedOnTaskSize": "",
"autoSummarizeLongDescriptionsAsTitles": "",
"configuredPresets": "",
"configuresTheModelUsedForTwoShortSummary": "",
"defaultWorkflowModelLaneActions": "",
"defaultWorkflowModelLanes": "",
"delete": "",
"edit": "",
"executorModel": "",
"fallsBackTo": "",
"loadingAvailableModels": "",
"loadingWorkflowModelLanes": "",
"modelLanes": "",
"modelPresets": "",
"name": "",
"noCap": "",
"noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow": "",
"noModelsAvailableConfigureAuthenticationFirst": "",
"noModelsAvailableConfigureAuthenticationFirst2": "",
"noPreset": "",
"noPresetsConfiguredYet": "",
"openAProjectToEditWorkflowModelLanes": "",
"overrideGlobalModelSettingsAtTheProjectLevel": "",
"presetEditor": "",
"reset": "",
"resetToDefaultNoCap": "",
"resetToInheritFromGlobal": "",
"resetToInheritFromWorkflow": "",
"reviewerModel": "",
"theseProjectOverridesApplyToTheActiveDefault": "",
"tokenCap": "",
"useDefault": "",
"useWorkflowDefault": "",
"whenEnabledMergeCommitMessagesIncludeAnAI": "",
"whenEnabledTasksCreatedWithoutATitleBut": ""
},
"remote": {
"acceptRoutes": "",
"advancedNamedTunnel": "",
"advancedSettings": "",
"authenticatedURL": "",
"authLinks": "",
"authLinkTokenType": "",
"automaticallyRestoreTunnelOnStartupIfItWas": "",
"cloudflare": "",
"cloudflaredInstalled": "cloudflared 安裝成功",
"cloudflaredIsInstalled": "",
"cloudflaredIsNotInstalled": "",
"cloudflaredMustBeInstalledToStartTheTunnel": "",
"enableShortLivedTokens": "",
"expiresAt": "",
"external": "",
"externalTunnelQRCode": "",
"generateQR": "",
"generateShortLivedToken": "",
"httpsYourDomainExample": "",
"ifHomebrewIsUnavailable": "",
"ingressURL": "",
"installCloudflared": "",
"installing": "",
"lastShortLivedTokenExpiresAt": "",
"manualInstall": "",
"ms": "",
"namedTunnelModeEnabled": "",
"noExpiry": "",
"persistentToken": "",
"persistentTokenRegenerated": "持久權杖已重新產生",
"qRSVGMarkup": "",
"regeneratePersistentToken": "",
"rememberLastRunningState": "",
"remoteAccess": "",
"remoteAccessCode": "",
"remoteAccessQRCode": "",
"remoteProvider": "",
"restarting": "重啟中…",
"scanThisQRCodeOnYourPhone": "",
"scanToConnect": "",
"scanToOpen": "",
"selectAProviderAboveToConfigureRemoteAccess": "",
"shortLivedToken": "",
"shortLivedTokenGenerated": "短期權杖已產生",
"shortLivedTTLMs": "",
"showURL": "",
"startFresh": "重新啟動",
"starting": "啟動中…",
"startTunnel": "啟動隧道",
"stopping": "停止中…",
"stopTunnel": "停止隧道",
"tailnetURL": "",
"tailscale": "",
"tailscaleFunnelWillExposeThisDashboardOnYour": "",
"tokenType": "",
"tunnelDetected": "",
"tunnelName": "",
"tunnelRestarted": "遠端隧道已重啟",
"tunnelStarted": "遠端隧道已啟動",
"tunnelStopped": "遠端隧道已停止",
"useExisting": "使用現有"
"tunnelToken": "",
"uRLAndQRGenerationUseTheSelectedToken": "",
"uRLNoHostnameOrPortConfigurationNeeded": "",
"useExisting": "使用現有",
"usingQuickTunnel": ""
},
"researchGlobal": {
"advancedExternalSearchProviders": "",
"alwaysOn": "",
"brave": "",
"builtInUsesAgentWebTools": "",
"configureBraveTavilyAndGoogleAPIKeysIn": "",
"customSearchEngineId": "",
"defaultMaxConcurrentRuns": "",
"defaultMaxDurationMs": "",
"defaultMaxSourcesPerRun": "",
"enabledSources": "",
"gitHub": "",
"googleCustomSearch": "",
"googleSearchCX": "",
"httpsSearxExampleCom": "",
"localDocs": "",
"maxSynthesisRounds": "",
"missingCredentialsForTheSelectedResearchProvider": "",
"openAuthentication": "",
"openAuthenticationSettings": "",
"requestTimeoutMs": "",
"researchDefaults": "",
"searchesAndFetchesUseTheAgentsNativeWebSearch": "",
"searchProvider": "",
"searXNG": "",
"searXNGURL": "",
"tavily": "",
"webSearch": ""
},
"researchProject": {
"alwaysOn": "",
"enabledSources": "",
"enableResearchInThisProject": "",
"github": "",
"llmSynthesis": "",
"localDocs": "",
"maxConcurrentRuns": "",
"maxDurationMs": "",
"maxSourcesPerRun": "",
"pageFetch": "",
"projectResearchSettings": "",
"requestTimeoutMs": "",
"webSearch": "",
"webSearchIsAlwaysEnabledConfigureTheSearch": ""
},
"resolveAllLocal": "全部解決:保留本機",
"resolveAllRemote": "全部解決:保留遠端",
"resolvedSuccess": "設定衝突已成功解決",
"resolveFailed": "無法解決衝突",
"resolving": "解決中...",
"runtimesRuntimes": {
"hermesRuntime": "",
"openClawRuntime": "",
"paperclipRuntime": ""
},
"scheduledEvals": {
"autoCreateTasks": "",
"disabled": "",
"enableScheduledEvalRunsForThisProject": "",
"evaluatorModel": "",
"evaluatorProvider": "",
"followUpPolicy": "",
"gpt5": "",
"intervalMs": "",
"leaveProviderAndModelBlankToInheritThe": "",
"openai": "",
"retentionDays": "",
"scheduledEvals": "",
"suggestOnly": ""
},
"scheduling": {
"addIgnoredPath": "",
"archiveAgentLog": "",
"archiveCompletedTasksAfterDays": "",
"backlogNoTaskAutoClaimIsExecutorOnly": "",
"browse": "",
"closeParenPeriod": "",
"compactModeKeepsArchiveSizeLowWhilePreserving": "",
"compactSummaryAndRecentEntries": "",
"completedTasksOlderThanTheThresholdAreMoved": "",
"docs": "",
"doNotArchiveAgentLogs": "",
"enableAutomaticTaskArchiving": "",
"enablePlanStalenessEnforcement": "",
"escalateHighFanOutBlockersOnlyAfterThey": "",
"fullAgentLog": "",
"globalMaxConcurrent": "",
"heartbeatScopeDiscipline": "",
"ignoredOverlapPaths": "",
"letEngineerAgentsAutoClaimBacklogTasks": "",
"lite": "",
"maxConcurrentTasks": "",
"maximumAgeInHoursBeforeAPlanIs": "",
"maximumConcurrentAgentsAcrossAllProjects": "",
"maximumConcurrentPlanningAgents": "",
"maximumStuckDetectorRetriesBeforeATaskIs": "",
"maxStuckRetries": "",
"maxTriageConcurrent": "",
"numberOfDaysATaskCanStayIn": "",
"off": "",
"optionalFileOrDirectoryPathsToIgnoreWhen": "",
"or": "",
"pollIntervalMs": "",
"preserveStepProgressOnStuckTaskRequeue": "",
"remove": "",
"scheduling": "",
"selectCurrentDir": "選取目前目錄",
"selectIgnoredOverlapPath": "選取忽略的重疊路徑"
"selectIgnoredOverlapPath": "選取忽略的重疊路徑",
"serializeTasksWithOverlappingFiles": "",
"staleHighFanOutEscalationHours": "",
"staleSpecThresholdHours": "",
"stepExecution": "",
"strictCoordinationFocusedHigherPerTickTokensLite": "",
"strictDefault": "",
"stuckTaskTimeoutMinutes": "",
"timeoutInMinutesForDetectingStuckTasksWhen": "",
"whenEnabledTasksThatModifyTheSameFiles": "",
"whenEnabledTasksWithStalePlansPROMPTMd": "",
"whenTheStuckDetectorKillsAndReQueues": ""
},
"scope": {
"globalBanner": "這些設定在所有 Fusion 專案中共用。",
@@ -5649,11 +6283,59 @@
},
"title": "設定",
"worktrees": {
"allowSilentSiblingBranchRenameDuringExecutorConflicts": "",
"alsoRebaseOntoLocalDefaultBranchHEAD": "",
"and": "",
"andCanHidePriorCommitsFromTheDefault": "",
"autoDetectFusionBinWorktrunkOrPATH": "",
"awaitingApproval": "等待審批——開啟審批以繼續。",
"branchCollisionSilentlyForksWorkOntoSiblingBranches": "",
"browse": "",
"browseWorktreesDirectory": "",
"closeParenPeriod": "",
"defaultsTo": "",
"defaultsToWorktreesLeaveEmptyUnlessOverriding": "",
"disabledByDefaultOptInWhenEnabledFusion": "",
"discouragedThisRestoresTheLegacyBehaviorWhereA": "",
"enableWorktrunkAndRequestApprovalToInstallThe": "",
"enableWorktrunkIntegration": "",
"failAndPauseTheTaskDefault": "",
"fallBackToFusionsNativeWorktreeBackend": "",
"forWorktreeCreateSyncPruneAndRemoveOperations": "",
"inAdditionToTheRemoteRebaseAboveAlso": "",
"installedAt": "",
"installTheWorktrunkBinaryBelowToEnableThis": "",
"installWorktrunk": "安裝 worktrunk 二進位檔",
"keepsProgressMovingBySwitchingToFusionApos": "",
"limitsTotalGitWorktreesIncludingInReviewTasks": "",
"maxWorktrees": "",
"offByDefaultOptInWhenEnabledCompleted": "",
"openApprovals": "開啟審批",
"optionalLeaveBlankToAutoResolveFusionWill": "",
"optionalSupports": "",
"pnpmInstallFrozenLockfile": "",
"randomNamesEGSwiftFalcon": "",
"rebaseFromRemoteBeforeMerge": "",
"rebaseRemote": "",
"recycleWorktrees": "",
"selectWorktreesDir": "選取工作樹目錄",
"tryAgain": "重試"
"shellCommandToRunInEachNewWorktree": "",
"stopsOnWorktrunkErrorsForExplicitOperatorRecovery": "",
"taskIDEGFN042": "",
"taskTitleEGFixLoginBug": "",
"tryAgain": "重試",
"useGitDefault": "",
"whenEnabledTheMergerFetchesFromTheConfigured": "",
"whenUnsetOnlyAffectsNewlyCreatedWorktrees": "",
"whichRemoteToFetchForThePreMerge": "",
"worktreeInitCommand": "",
"worktreeNamingStyle": "",
"worktrees": "",
"worktreesDirectory": "",
"worktrunk": "",
"worktrunkBinaryPath": "",
"worktrunkFailureBehavior": "",
"worktrunkIntegration": ""
}
},
"setup": {

View File

@@ -142,6 +142,14 @@
"notConfigured": ""
}
},
"settings": {
"nodeRouting": {
"statusConnecting": "",
"statusError": "",
"statusOffline": "",
"statusOnline": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {

View File

@@ -874,6 +874,7 @@ export default interface Resources {
"resetDayMonthly": "Day of month (1–28)",
"resetDayWeekly": "Day of week (0=Sun)",
"resetting": "Resetting...",
"resizeSidebar": "Resize agents sidebar",
"result": "Result",
"resultCreated_one": "{{count}} created",
"resultCreated_other": "{{count}} created",
@@ -1909,7 +1910,6 @@ export default interface Resources {
"title": "Create room"
},
"dashboard": {
"brandName": "Fusion",
"initializingDashboard": "Initializing dashboard...",
"loadingMessage": "Loading Fusion dashboard",
"loadingProgress": "Dashboard loading progress",
@@ -5460,10 +5460,18 @@ export default interface Resources {
"cancel": "Cancel",
"save": "Save"
},
"agentPermissions": {
"agentPermissions": "Agent Permissions",
"agentProvisioningApprovals": "Agent Provisioning Approvals",
"configureProjectLevelApprovalBehaviorForDurableProvisioning": " Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete). ",
"perAgentSettingsOverrideProjectDefaultsEachCategory": "Per-agent settings override project defaults. Each category controls a separate approval gate."
},
"appearance": {
"hideAISessionNotificationBanners": "Hide AI session notification banners",
"language": "Language",
"languageAuto": "Auto",
"languageAutoHint": "Follow the browser language",
"suppressTheLdquoNeedsYourInputRdquoBanner": " Suppress the &ldquo;needs your input&rdquo; banner that appears when AI sessions are awaiting input or have failed. ",
"title": "Appearance"
},
"auth": {
@@ -5503,10 +5511,51 @@ export default interface Resources {
"title": "Authentication",
"waitingForLogin": "Waiting for login…"
},
"authentication": {
"enterAPIKey": "Enter API key",
"key": "Key: "
},
"backups": {
"02": "0 2 * * *",
"03": "0 3 * * *",
"agentsOnlyFusionAgentMemory": "Agents only (.fusion/agent-memory)",
"allProjectAgents": "All (project + agents)",
"and": "...and ",
"backupCreated": "Backup created successfully",
"backupDirectory": "Backup Directory",
"backupNow": "Backup Now",
"creating": "Creating…"
"backupS": " backup(s)",
"backupScheduleCron": "Backup Schedule (Cron)",
"backups": "backups",
"creating": "Creating…",
"cronExpressionForBackupTimingDefault02": " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) ",
"cronExpressionForMemoryBackupTimingDefault0": "Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).",
"currentBackups": "Current Backups",
"databaseBackups": "Database Backups",
"directoryForBackupFilesRelativeToProjectRoot": "Directory for backup files, relative to project root",
"directoryForMemoryBackupsRelativeToProjectRoot": "Directory for memory backups, relative to project root.",
"enableAutomaticDatabaseBackups": " Enable automatic database backups ",
"enableAutomaticMemoryBackups": " Enable automatic memory backups ",
"fusionBackups": ".fusion/backups",
"fusionBackupsMemory": ".fusion/backups/memory",
"invalidCronExpressionFormat": "Invalid cron expression format",
"loadingBackupInfo": "Loading backup info…",
"memoryBackupDirectory": "Memory Backup Directory",
"memoryBackupScheduleCron": "Memory Backup Schedule (Cron)",
"memoryBackupScope": "Memory Backup Scope",
"memoryBackups": "Memory Backups",
"memoryRetentionCount": "Memory Retention Count",
"more": " more",
"mustBeBetween1And100": "Must be between 1 and 100",
"numberOfBackupFilesToKeepOldestAre": "Number of backup files to keep (oldest are deleted first). Range: 1-100.",
"numberOfMemoryBackupsToKeepOldestAre": "Number of memory backups to keep (oldest are deleted first). Range: 1-100.",
"pathCannotContainParentDirectoryTraversal": "Path cannot contain parent directory traversal (..)",
"projectOnlyFusionMemory": "Project only (.fusion/memory)",
"retentionCount": "Retention Count",
"totalSize": "total size",
"view": "View ",
"whenEnabledProjectAndAgentMemoryFilesAre": "When enabled, project and agent memory files are backed up automatically on a schedule.",
"whenEnabledTheDatabaseIsBackedUpAutomatically": "When enabled, the database is backed up automatically on a schedule"
},
"clearToDefault": "Reset to default",
"cliAgents": {
@@ -5533,14 +5582,80 @@ export default interface Resources {
"saveFailed": "Failed to save CLI agent settings"
},
"closeModal": "Close conflict modal",
"commands": {
"buildCommand": "Build Command",
"commandUsedToBuildTheProjectInjectedInto": "Command used to build the project — injected into generated task specs",
"commandUsedToRunTestsInjectedIntoGenerated": "Command used to run tests — injected into generated task specs",
"commands": "Commands",
"eGPnpmBuild": "e.g. pnpm build",
"eGPnpmTest": "e.g. pnpm test",
"testCommand": "Test Command"
},
"conflictModalTitle": "Resolve Settings Conflicts",
"experimental": {
"experimentalFeatures": "Experimental Features",
"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": "Feature Flags"
},
"footer": {
"help": "Help",
"version": "Version {{version}}"
},
"general": {
"14Days": "14 days",
"200": "200",
"25": "25",
"3000": "3000",
"30Days": "30 days",
"60Days": "60 days",
"7Days": "7 days",
"90Days": "90 days",
"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. ",
"autoCleanupOldChats": "Auto-cleanup old chats",
"autoPruneOldMail": "Auto-prune old mail",
"bannerFiresWhenTodoCountIsStrictlyGreater": "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.",
"builtInWorkflows": "Built-in workflows",
"capacityRiskBanner": "Capacity Risk Banner",
"chatHistory": "Chat history",
"chatRooms": "Chat Rooms",
"completionDocumentationAutomation": "Completion Documentation Automation",
"controlsHowFutureTaskSpecsHandleReleaseNote": " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ",
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ",
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.",
"defaultTrackingModeForNewTasks": "Default tracking mode for new tasks",
"deleteChatSessionsAndRoomsThatHaveBeen": "Delete chat sessions and rooms that have been idle for this many days. Default: Off.",
"deleteInboxOutboxMessagesOlderThanThisMany": "Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.",
"disabledBuiltInWorkflowsAreHiddenFromWorkflow": "Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve.",
"enableSummarizationInProjectModelsToConfigureThatModel": " Enable summarization in Project Models to configure that model.",
"fN": "FN",
"general": "General",
"gitHubTracking": "GitHub Tracking",
"hardCapOnTheSynthesizedEarlierRoomContext": "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000.",
"learnMore": "Learn more",
"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. ",
"newTasksInheritThisCustomWorkflowsStepsOverridable": "New tasks inherit this custom workflow's steps (overridable per task)",
"numberOfMostRecentChatRoomMessagesKept": "Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.",
"off": "Off",
"offDefault": "Off (default)",
"onForNewTasks": "On for new tasks",
"operationalLogRetention": "Operational log retention",
"ownerRepo": "owner/repo",
"prefixForNewTaskIDsEGKB": "Prefix for new task IDs (e.g. KB, PROJ)",
"prefixMustBe110UppercaseLetters": "Prefix must be 1–10 uppercase letters",
"projectDefaultTrackingRepo": "Project default tracking repo",
"recentVerbatimRoomMessages": "Recent verbatim room messages",
"requireChangelogUpdateExistingChangelog": "Require changelog update (existing changelog)",
"requireChangesetChangesetMd": "Require changeset (.changeset/*.md)",
"roomCompactionFetchLimit": "Room compaction fetch limit",
"roomSummaryMaxCharacters": "Room summary max characters",
"searchTheTrackingRepoForLikelyDuplicatesBefore": " Search the tracking repo for likely duplicates before opening a new issue ",
"settingsSaved": "Settings saved",
"showCapacityRiskBanner": " Show capacity risk banner ",
"showQuickChatButton": " Show quick chat button ",
"showTheFloatingChatButtonInTheDashboard": "Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.",
"taskPrefix": "Task Prefix",
"todoThreshold": "Todo threshold",
"trackingIssuesUseThisTaskAposSTitle": " Tracking issues use this task&apos;s title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. ",
"upToDate": "You're up to date ✓",
"updateAvailablePrefix": "v{{version}} available",
"updateCheckFailed": "Failed to check for updates",
@@ -5549,7 +5664,88 @@ export default interface Resources {
"updateNow": "Update now",
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
"updating": "Updating…"
"updating": "Updating…",
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
"useEphemeralTaskWorkerAgents": " Use ephemeral task-worker agents ",
"warnOnTheBoardWhenTodoWorkExceeds": "Warn on the board when todo work exceeds the threshold and no idle agents are available.",
"whenEnabledDefaultFusionSpawnsShortLived": " When enabled (default), Fusion spawns short-lived ",
"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. ",
"workflowsOrChangelogModeWhenContributorsShouldUpdate": " workflows, or changelog mode when contributors should update an existing changelog file. "
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. ",
"autoReloadDashboardOnVersionChange": " Auto-reload dashboard on version change ",
"cLIBinaryOnPATH": " CLI binary on PATH ",
"cLIBySpawning": " CLI by spawning",
"checkForThe": " Check for the ",
"checkForUpdatesAutomatically": " Check for updates automatically ",
"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. ",
"dailyRecommended": "Daily (recommended)",
"disableThisIfYourLocalDevProcessIs": ". Disable this if your local dev process is the source of truth and you don&apos;t want any outdated globally-installed binary executed during the probe. ",
"frequency": "Frequency",
"general": "General",
"globalDefaultTrackingRepo": "Global default tracking repo",
"leaveBothThinkingTogglesOffToKeepThe": " Leave both thinking toggles off to keep the original default behavior. This only controls persisted ",
"manualOnlyNeverAutoCheck": "Manual only — never auto-check",
"onStartupOncePerServerLaunch": "On startup — once per server launch",
"ownerRepo": "owner/repo",
"projectsInheritThisValueWhenTheyDoNot": "Projects inherit this value when they do not set a project default tracking repo.",
"rowsAndDoesNotAffectAssistantTextOr": " rows and does not affect assistant text or tool rows. ",
"saveAIThinkingForEphemeralTaskWorkerAgents": " Save AI thinking for ephemeral / task-worker agents ",
"saveAIThinkingForPermanentAgents": " Save AI thinking for permanent agents ",
"saveAIThinkingLogs": "Save AI thinking logs",
"saveToolOutputInAgentLogs": " Save tool output in agent logs ",
"updates": "Updates",
"weekly": "Weekly",
"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. ",
"whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen": " 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. ",
"whenEnabledFusionChecksNpmForNewVersions": " When enabled, Fusion checks npm for new versions of",
"whenEnabledTheDashboardProbesForAGlobally": " When enabled, the dashboard probes for a globally-installed"
},
"globalModels": {
"allow": "allow",
"commaSeparatedValuesSentToOpenRouterModelSync": "Comma-separated values sent to OpenRouter model sync.",
"controlsHowMuchReasoningEffortTheAIModel": "Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.",
"default": "Default",
"default2": "default",
"defaultAIModelUsedForTaskExecutionWhen": "Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.",
"defaultModel": "Default Model",
"deny": "deny",
"fallbackModel": "Fallback Model",
"flowAndPublishesThemUnderTheOpencodeGo": " flow and publishes them under the opencode-go provider in model pickers. ",
"fusion": "Fusion",
"globalBaselineModelsForEachAIRoleProject": " Global baseline models for each AI role. Project settings can override these per-project. ",
"httpsRunfusionAi": "https://runfusion.ai",
"latency": "latency",
"leaveEmptyToOmitThisHeaderDefaultFusion": "Leave empty to omit this header. Default: Fusion.",
"leaveEmptyToOmitThisHeaderDefaultHttps": "Leave empty to omit this header. Default: https://runfusion.ai.",
"modelLanes": "Model Lanes",
"noFallback": "No fallback",
"openRouterAdvanced": "OpenRouter advanced",
"openRouterAllowFallbacks": "OpenRouter allow fallbacks",
"openRouterHTTPReferer": "OpenRouter HTTP-Referer",
"openRouterOutputModalitiesFilter": "OpenRouter output_modalities filter",
"openRouterRoutingIgnore": "OpenRouter routing ignore",
"openRouterRoutingOnly": "OpenRouter routing only",
"openRouterRoutingOrder": "OpenRouter routing order",
"openRouterRoutingSort": "OpenRouter routing sort",
"openRouterSupportedParametersFilter": "OpenRouter supported_parameters filter",
"openRouterXTitle": "OpenRouter X-Title",
"openaiAnthropic": "openai, anthropic",
"price": "price",
"providerName": "provider-name",
"requireParameters": " Require parameters ",
"startupModelSync": "Startup Model Sync",
"syncOpenRouterModelListAtStartup": " Sync OpenRouter model list at startup ",
"syncOpencodeGoModelListAtStartup": " Sync opencode-go model list at startup ",
"text": "text",
"thinkingEffort": "Thinking Effort",
"throughput": "throughput",
"toolsStructuredOutputs": "tools, structured_outputs",
"useDefault": "Use default",
"usedAutomaticallyIfThePrimaryDefaultModelHits": "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.",
"whenEnabledStartupFetchesTheLatestAvailableModels": " When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. ",
"whenEnabledStartupRefreshesModelsThroughTheLocal": " When enabled, startup refreshes models through the local "
},
"header": {
"discord": "Discord"
@@ -5569,17 +5765,179 @@ export default interface Resources {
"keepRemote": "Keep Remote",
"loading": "Loading…",
"memory": {
"03": "0 3 * * *",
"agentsGetMemorySearchMemoryGetAndMemory": "Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.",
"agentsSearchWithQmdFirstFallBackTo": ". Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed. ",
"autoSummarizeMemory": " Auto-Summarize Memory ",
"automaticallyCompactMemoryWhenItExceedsTheThreshold": "Automatically compact memory when it exceeds the threshold on a schedule",
"bytesUpdated": " bytes · updated ",
"cannotSave": "Cannot save: ",
"checkingMemoryWriteAccess": "Checking memory write access...",
"compactSelectedFile": "Compact Selected File",
"compacting": "Compacting…",
"compactionThresholdChars": "Compaction Threshold (chars)",
"cronExpressionForAutoSummarizeScheduleDefaultDaily": "Cron expression for auto-summarize schedule (default: daily at 3 AM)",
"cronExpressionForDreamProcessing": "Cron expression for dream processing.",
"dreamCompleted": "Dream processing completed",
"dreamNow": "Dream Now",
"dreamSchedule": "Dream Schedule",
"dreaming": " Dreaming… ",
"enableMemoryTools": " Enable memory tools ",
"failedToLoadBackendStatus": "Failed to load backend status: ",
"for": "for \"",
"installQmd": "Install qmd",
"installing": "Installing…",
"loadingMemory": "Loading memory…",
"manuallyTriggerDreamProcessingNow": "Manually trigger dream processing now.",
"memory": "Memory",
"memoryCompacted": "Memory file compacted",
"memoryFile": "Memory File",
"memoryIsConfiguredWithAReadOnlyBackend": " Memory is configured with a read-only backend. You can view the file, but saving is disabled. ",
"memoryIsCurrentlyDisabledYouCanViewThe": " Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled. ",
"memoryLivesIn": " Memory lives in ",
"memorySaved": "Memory saved",
"memoryWillBeCompactedWhenItExceedsThis": "Memory will be compacted when it exceeds this character count",
"noMatchingMemoryFound": "No matching memory found.",
"processDreamsFromDailyMemory": " Process dreams from daily memory ",
"qmd": " qmd ",
"qmdIsNotInstalledSearchWillUseLocal": " qmd is not installed. Search will use local files. Install indexed retrieval: ",
"result": " result",
"runsTheSameQmdBackedMemorySearchPath": "Runs the same qmd-backed memory_search path agents use.",
"saveMemory": "Save Memory",
"scheduleCron": "Schedule (cron)",
"searchMemoryWithQmd": "Search memory with qmd",
"testRetrieval": "Test Retrieval",
"testing": "Testing…"
"testing": "Testing…",
"turnsDailyNotesIntoDREAMSMdAndPromotes": "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md."
},
"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&apos;s reviewer/validator model.",
"aIMerge": "AI merge",
"aIMergeDefaultAIMergesInAClean": "AI merge (default) — AI merges in a clean room, an AI reviewer audits with retries, then lands",
"aIModeMergesTheTaskBranchIntoAn": " AI mode merges the task branch into an isolated clean-room checkout at the target branch&apos;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. ",
"aIOnly": "AI only",
"aIOnlyAIAutoResolveAIRetryNever": "AI only — AI → auto-resolve → AI retry; never silently pick a side",
"abort": "Abort",
"abortOneAIAttemptRequireManualResolutionIf": "Abort — one AI attempt; require manual resolution if it fails",
"addFusionAsCoAuthorOnCommits": " Add Fusion as co-author on commits ",
"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). ",
"allowAIMergeToSyncADirtyChecked": " Allow AI merge to sync a dirty checked-out integration branch ",
"alwaysPreserveDirectMergeCommitHistory": "Always preserve direct-merge commit history",
"alwaysSquashDirectMerges": "Always squash direct merges",
"and": ", and ",
"and2": " and ",
"areListedFirstOrChoose": " are listed first — or choose ",
"autoCompletionMode": "Auto-completion mode",
"autoDetect": "auto-detect",
"autoDetectOriginHEADMain": "(auto-detect — origin/HEAD → main)",
"autoKeepsTodayAposSSquashBehaviorFor": " Auto keeps today&apos;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 ",
"autoMergeCompletedTasks": " Auto-merge completed tasks ",
"autoMergeConflictRetries": "Auto-merge conflict retries",
"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. ",
"autoMergeWillRunRebaseConflictResolutionAnd": " Auto-merge will run rebase, conflict resolution, and squash commits inside the project root (the user&apos;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&apos;t, merges may fail or touch the user&apos;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). ",
"autoResolveConflictsInLockFilesAndGenerated": " Auto-resolve conflicts in lock files and generated files ",
"autoSquashSingleSubstantiveBranchesPreserveMultiSubstantive": "Auto — squash single-substantive branches, preserve multi-substantive history",
"autoSyncProjectCheckoutAfterMerge": "Auto-sync project checkout after merge",
"block": "Block",
"blockStrict": "Block (strict)",
"both": " Both ",
"branchName": "branch name",
"checkingForLegacyAutoMergeStamps": "Checking for legacy auto-merge stamps…",
"coAuthorEmail": "Co-author Email",
"coAuthorName": "Co-author Name",
"conflictFallbackStrategy": "Conflict Fallback Strategy",
"controlsThePostMergeAuditGate": " Controls the post-merge audit gate. ",
"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. ",
"createMonitorAndMergeAGitHubPullRequest": "Create, monitor, and merge a GitHub pull request",
"custom": "Custom…",
"dangerousCompatibilityEscapeHatchLeaveOffUnlessYou": " 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. ",
"defaultLogsFindingsButAutoCompletesTheMerge": " (default) logs findings but auto-completes the merge. ",
"deterministicLegacyRebaseConflictStrategyAuditPipeline": "Deterministic (legacy) — rebase / conflict-strategy / audit pipeline",
"directMergeCommitRouting": "Direct merge commit routing",
"directMergeIntoTheCurrentBranch": "Direct merge into the current branch",
"emailUsedInThe": "Email used in the ",
"enableTestMode": " Enable test mode ",
"fastForwardOfLocalMainFrom": " + fast-forward of local main from ",
"fastForwardOnlySkipDirtyWorktrees": "Fast-forward only — skip dirty worktrees",
"finalFallback": "final fallback",
"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. ",
"flipOverlappingFilesToPreferTheTaskBranch": "Flip overlapping files to prefer the task branch (default)",
"forcesAllAILanesToUseTheDeterministic": "Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.",
"fusion": "Fusion",
"gitHubAuthMode": "GitHub auth mode",
"gitHubAuthentication": "GitHub Authentication",
"gitHubCLIGhAuth": "GitHub CLI (gh auth)",
"gitHubPersonalAccessToken": "GitHub personal access token",
"gitRemoteToPushToEGOrigin": "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".",
"ignoreOverlapDetectionPreserveLegacyBehavior": "Ignore overlap detection — preserve legacy behavior",
"inYourProjectRootWillShowTheNew": " in your project root will show the new commits inverted as &quot;staged changes&quot; until you pull manually. Only applies to direct merges. ",
"includeTaskIDInCommitScope": " Include task ID in commit scope ",
"insteadOf": " instead of ",
"integrationBranch": "Integration branch",
"integrationWorktree": "Integration worktree",
"isTheLegacyBehavior": " is the legacy behavior: ",
"isTheStricterOptInModeThatRefuses": " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ",
"legacy": " → legacy ",
"legacy2": "Legacy ",
"legacyAutoMergeStamp": " legacy auto-merge stamp",
"legacyAutoMergeStampCleanup": "Legacy auto-merge stamp cleanup",
"legacyIntegrationBranchMode": "Legacy integration-branch mode.",
"maxAIReviewPasses": "Max AI review passes",
"merge": "Merge",
"moreDetails": "More details",
"nameUsedInThe": "Name used in the ",
"noLegacyAutoMergeStampsToCleanUp": " No legacy auto-merge stamps to clean up. ",
"noreplyRunfusionAi": "noreply@runfusion.ai",
"off": "Off",
"offLeaveTheProjectRootStaleLegacyBehavior": "Off — leave the project root stale (legacy behavior)",
"offSkipAudit": "Off (skip audit)",
"optionsStartWithABestEffort": " options start with a best-effort ",
"origin": "origin",
"personalAccessToken": "Personal access token",
"pickALocalBranchFromTheDropdownCommon": "). Pick a local branch from the dropdown — common integration names like ",
"positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.",
"postMergeAuditMode": "Post-merge audit mode",
"pushRemote": "Push Remote",
"pushToRemoteAfterMerge": " Push to remote after merge ",
"readyToCleanUp": " ready to clean up.",
"retriesTheAIAgentRatherThanAutoPicking": " retries the AI agent rather than auto-picking a side. ",
"reuseTaskWorktreeDefault": "Reuse task worktree (default)",
"skipsTheAuditEntirelySwitchingToOffIs": " skips the audit entirely. Switching to Off is recommended only if you trust your branches don&apos;t silently drop edits. ",
"smart": "Smart",
"smartConflictResolution": " Smart conflict resolution ",
"smartPreferMain": "Smart, prefer main",
"smartPreferMainOnFallbackFetchFfOrigin": "Smart, prefer main on fallback — fetch+ff origin → AI → auto-resolve → -X ours (default; protects just-merged sibling work)",
"smartPreferMainOverlapGuard": "Smart Prefer Main Overlap Guard",
"smartPreferTask": "Smart, prefer task",
"smartPreferTaskOnFallbackFetchFfOrigin": "Smart, prefer task on fallback — fetch+ff origin → AI → auto-resolve → -X theirs (legacy \"smart\" behavior; task branch wins)",
"snapsCleanlyWhenTheWorktreeHasNoEdits": " snaps cleanly when the worktree has no edits and skips otherwise. ",
"snapshotsRealLocalEditsAsAPatchAgainst": " 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. ",
"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 ",
"soMainWinsProtectsJustMergedSiblingWork": " so main wins — protects just-merged sibling work and is the new default. ",
"soTheTaskBranchWinsFastButCan": " so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression). ",
"stashFastForwardDefaultPreserveLocalEdits": "Stash + fast-forward (default) — preserve local edits",
"stopsAfterTheFirstAIAttemptAndWaits": " stops after the first AI attempt and waits for a human. ",
"symbolicRefFallback": " symbolic ref → fallback ",
"theCanonicalBranchFusionMergesTasksIntoAnd": " The canonical branch Fusion merges tasks into and uses as the reference for all ahead/behind / overlap / pre-rebase computations. Leave on ",
"theLegacyMergeSettingsBelowDoNotApply": "The legacy merge settings below do not apply while AI merge is on.",
"toResolveViaTheStandardCascade": " to resolve via the standard cascade (",
"toTypeABranchThatDoesnAposT": " to type a branch that doesn&apos;t exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ",
"trailer": " trailer",
"trailerCreditingFusionRecognizedByGitHubForShared": " trailer crediting Fusion (recognized by GitHub for shared attribution). ",
"useDropdown": " Use dropdown ",
"useProjectRootLegacy": "Use project root (legacy)",
"uses": " uses ",
"valuesFromOlderSettingsAreMigratedAutomatically": " values from older settings are migrated automatically.",
"warn": "Warn",
"warnDefaultLogFindingsContinue": "Warn (default; log findings, continue)",
"warnOnlyKeepLegacyMainWinsFallback": "Warn only — keep legacy main-wins fallback",
"whenDisabledMergeCommitMessagesOmitTheTask": "When disabled, merge commit messages omit the task ID from the scope (e.g. ",
"whenEnabledCommitsMadeByFusionKeepYour": " When enabled, commits made by Fusion keep your git identity as the primary author and append a ",
"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.",
"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.",
"whenEnabledTasksThatPassReviewAreAutomatically": "When enabled, tasks that pass review are automatically merged into the main branch",
"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.",
"whenUsingSmartPreferMainAutomaticallyPreferThe": " When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. "
},
"mergeManually": "Merge Manually",
"mobileNav": {
@@ -5612,38 +5970,314 @@ export default interface Resources {
"project": "Specific to this project"
}
},
"nodeRouting": {
"blockExecution": "Block execution",
"configureHowTasksAreRoutedToExecutionNodes": "Configure how tasks are routed to execution nodes.",
"defaultExecutionNode": "Default Execution Node",
"fallBackToLocal": "Fall back to local",
"localExecutionNoDefaultNode": "Local execution (no default node)",
"nodeRouting": "Node Routing",
"selectedNode": "Selected node:",
"theseSettingsApplyAtTheProjectLevel": "These settings apply at the project level.",
"unavailableNodePolicy": "Unavailable Node Policy",
"usedWhenATaskHasNoNodeOverride": "Used when a task has no node override. Node status is shown for safer routing selection."
},
"nodeSync": {
"alwaysAsk": "Always ask",
"automaticallySynchronizeSettingsBetweenThisNodeAndConnected": "Automatically synchronize settings between this node and connected remote nodes",
"conflictResolution": "Conflict resolution",
"enableAutomaticSettingsSync": " Enable automatic settings sync ",
"every15Minutes": "Every 15 minutes",
"every1Hour": "Every 1 hour",
"every30Minutes": "Every 30 minutes",
"every5Minutes": "Every 5 minutes",
"includeAPIKeysAndOAuthTokensInSync": "Include API keys and OAuth tokens in sync operations",
"keepLocal": "Keep local",
"keepRemote": "Keep remote",
"lastWriteWins": "Last write wins",
"nodeSync": "Node Sync",
"syncInterval": "Sync interval",
"syncModelAuthCredentials": " Sync model auth credentials ",
"workflowSettingsNotSynced": "Workflow settings are not synced across nodes yet."
},
"notifications": {
"accessTokenOptional": "Access token (optional)",
"advanced": "Advanced",
"allFailuresLegacy": "All failures (legacy)",
"baseURLForDeepLinksInNotificationsWhen": " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. ",
"customNtfyServerURLOptional": "Custom ntfy server URL (optional)",
"dashboardHostname": "Dashboard Hostname",
"discord": "Discord",
"enable": " Enable ",
"failureNotificationDelayMs": "Failure notification delay (ms)",
"failureNotificationMode": "Failure notification mode",
"format": "Format",
"general": " General: ",
"generic": "Generic",
"howLongAFailureMustPersistBeforeA": " How long a failure must persist before a push notification is sent. 0 = notify immediately. ",
"httpLocalhost3000": "http://localhost:3000",
"httpsHooksExampleCom": "https://hooks.example.com/...",
"httpsNtfySh": "https://ntfy.sh",
"learnMoreAboutNtfySh": " Learn more about ntfy.sh ",
"leaveBlankToKeepTheDefaultServerHttps": " Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. ",
"leaveBlankToPublishWithoutAuthenticationWhenSet": " Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. ",
"messageInbox": " Message inbox: ",
"mustBeAValidURLStartingWithHttp": " Must be a valid URL starting with http:// or https:// ",
"myTopicName": "my-topic-name",
"notifications": "Notifications",
"notifyOnEvents": "Notify on events",
"ntfy": "ntfy",
"ntfyTopic": "ntfy Topic",
"roomReply": " Room reply: ",
"sending": "Sending…",
"slack": "Slack",
"stickyFailuresOnlyDefault": "Sticky failures only (default)",
"stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits": "Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.",
"terminalFailuresOnlySuppressAutoRetried": "Terminal failures only (suppress auto-retried)",
"testMessageInbox": "Test message inbox",
"testNotification": "Test notification",
"testRoomReply": "Test room reply"
"testRoomReply": "Test room reply",
"tk": "tk_...",
"topicMustBe164AlphanumericHyphenOr": " Topic must be 1–64 alphanumeric, hyphen, or underscore characters ",
"webhook": "Webhook",
"webhookNotifications": " Webhook notifications ",
"webhookURL": "Webhook URL",
"yourNtfyShTopicName164Alphanumeric": " Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters)."
},
"plugins": {
"fusionPlugins": " Fusion Plugins ",
"piExtensions": " Pi Extensions ",
"pluginManagerType": "Plugin manager type",
"plugins": "Plugins"
},
"projectModels": {
"aIMergeCommitSummaries": " AI merge commit summaries ",
"aITitleAndGitCommitMessageSummarization": " AI Title and Git Commit Message Summarization ",
"addPreset": " Add Preset ",
"advancedWorkflowPolicy": " Advanced workflow policy ",
"autoSelectPresetBasedOnTaskSize": " Auto-select preset based on task size ",
"autoSummarizeLongDescriptionsAsTitles": " Auto-summarize long descriptions as titles ",
"automaticallyCompactContextWhenApproachingThisTokenCount": "Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count.",
"configuredPresets": "Configured presets",
"configuresTheModelUsedForTwoShortSummary": " Configures the model used for two short-summary jobs: auto-generating task titles from long descriptions, and generating merge commit summaries from step commits and diff stats. ",
"defaultWorkflowModelLaneActions": "Default workflow model lane actions",
"defaultWorkflowModelLanes": "Default workflow model lanes",
"delete": " Delete ",
"edit": " Edit ",
"executorModel": "Executor model",
"fallsBackTo": " Falls back to: ",
"loadingAvailableModels": "Loading available models…",
"loadingWorkflowModelLanes": "Loading workflow model lanes…",
"modelLanes": "Model Lanes",
"modelPresets": "Model Presets",
"name": "Name",
"noCap": "No cap",
"noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow": " No models available. Configure authentication before selecting workflow model lanes. ",
"noModelsAvailableConfigureAuthenticationFirst": " No models available. Configure authentication first. ",
"noModelsAvailableConfigureAuthenticationFirst2": "No models available. Configure authentication first.",
"noPreset": "No preset",
"noPresetsConfiguredYet": "No presets configured yet.",
"openAProjectToEditWorkflowModelLanes": "Open a project to edit workflow model lanes.",
"overrideGlobalModelSettingsAtTheProjectLevel": " Override global model settings at the project level. Each lane controls a specific AI usage context. Unset lanes inherit from the corresponding global lane. The Project Default Model is the fallback for this project when a more specific lane is unset. ",
"presetEditor": "Preset editor",
"reset": " Reset ",
"resetToDefaultNoCap": "Reset to default (no cap)",
"resetToInheritFromGlobal": "Reset to inherit from global",
"resetToInheritFromWorkflow": "Reset to inherit from workflow",
"reviewerModel": "Reviewer model",
"theseProjectOverridesApplyToTheActiveDefault": " These project overrides apply to the active default workflow. ",
"tokenCap": "Token Cap",
"useDefault": "Use default",
"useWorkflowDefault": "Use workflow default",
"whenEnabledMergeCommitMessagesIncludeAnAI": " When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model. ",
"whenEnabledTasksCreatedWithoutATitleBut": " When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters). The same model is also used to generate fallback merge commit message bodies when the branch's commit log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue titles when a tracked task has no title yet. "
},
"remote": {
"acceptRoutes": " Accept routes ",
"advancedNamedTunnel": "Advanced (Named Tunnel)",
"advancedSettings": "Advanced Settings",
"authLinkTokenType": "Auth link token type",
"authLinks": "Auth Links",
"authenticatedURL": "Authenticated URL:",
"automaticallyRestoreTunnelOnStartupIfItWas": "Automatically restore tunnel on startup if it was running when last stopped.",
"cloudflare": "Cloudflare",
"cloudflaredInstalled": "cloudflared installed successfully",
"cloudflaredIsInstalled": "cloudflared is installed",
"cloudflaredIsNotInstalled": "cloudflared is not installed",
"cloudflaredMustBeInstalledToStartTheTunnel": "cloudflared must be installed to start the tunnel",
"enableShortLivedTokens": " Enable short-lived tokens ",
"expiresAt": " · Expires at {{expiresAt}}",
"external": "External ",
"externalTunnelQRCode": "External tunnel QR code",
"generateQR": "Generate QR",
"generateShortLivedToken": "Generate short-lived token",
"httpsYourDomainExample": "https://your-domain.example",
"ifHomebrewIsUnavailable": "If Homebrew is unavailable: ",
"ingressURL": "Ingress URL",
"installCloudflared": "Install cloudflared",
"installing": "Installing…",
"lastShortLivedTokenExpiresAt": "Last short-lived token expires at ",
"manualInstall": "Manual install: ",
"ms": "ms)",
"namedTunnelModeEnabled": "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below.",
"noExpiry": " · No expiry",
"persistentToken": "Persistent token",
"persistentTokenRegenerated": "Persistent token regenerated",
"qRSVGMarkup": "QR SVG markup",
"regeneratePersistentToken": "Regenerate persistent token",
"rememberLastRunningState": " Remember last running state ",
"remoteAccess": "Remote Access",
"remoteAccessCode": "Remote access code:",
"remoteAccessQRCode": "Remote access QR code",
"remoteProvider": "Remote provider",
"restarting": "Restarting…",
"scanThisQRCodeOnYourPhone": "Scan this QR code on your phone",
"scanToConnect": "Scan to connect:",
"scanToOpen": "Scan to open:",
"selectAProviderAboveToConfigureRemoteAccess": "Select a provider above to configure remote access.",
"shortLivedTTLMs": "Short-lived TTL (ms)",
"shortLivedToken": "Short-lived token",
"shortLivedTokenGenerated": "Short-lived token generated",
"showURL": "Show URL",
"startFresh": "Start Fresh",
"startTunnel": "Start Tunnel",
"starting": "Starting…",
"stopTunnel": "Stop Tunnel",
"stopping": "Stopping…",
"tailnetURL": "Tailnet URL:",
"tailscale": "Tailscale",
"tailscaleFunnelWillExposeThisDashboardOnYour": "Tailscale Funnel will expose this dashboard on your tailnet's public ",
"tokenType": " Token type: ",
"tunnelDetected": " tunnel detected",
"tunnelName": "Tunnel name",
"tunnelRestarted": "Remote tunnel restarted",
"tunnelStarted": "Remote tunnel started",
"tunnelStopped": "Remote tunnel stopped",
"useExisting": "Use Existing"
"tunnelToken": "Tunnel token",
"uRLAndQRGenerationUseTheSelectedToken": " URL and QR generation use the selected token type. ",
"uRLNoHostnameOrPortConfigurationNeeded": " URL — no hostname or port configuration needed.",
"useExisting": "Use Existing",
"usingQuickTunnel": "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed."
},
"researchGlobal": {
"advancedExternalSearchProviders": "Advanced — external search providers",
"alwaysOn": "Always on",
"brave": "Brave",
"builtInUsesAgentWebTools": " Built-in (uses agent web tools) ",
"configureBraveTavilyAndGoogleAPIKeysIn": " Configure Brave, Tavily, and Google API keys in Authentication. ",
"customSearchEngineId": "custom-search-engine-id",
"defaultMaxConcurrentRuns": "Default Max Concurrent Runs",
"defaultMaxDurationMs": "Default Max Duration (ms)",
"defaultMaxSourcesPerRun": "Default Max Sources Per Run",
"enabledSources": "Enabled Sources",
"gitHub": " GitHub ",
"googleCustomSearch": "Google Custom Search",
"googleSearchCX": "Google Search CX",
"httpsSearxExampleCom": "https://searx.example.com",
"localDocs": " Local Docs ",
"maxSynthesisRounds": "Max Synthesis Rounds",
"missingCredentialsForTheSelectedResearchProvider": " Missing credentials for the selected research provider. ",
"openAuthentication": " Open Authentication ",
"openAuthenticationSettings": " Open Authentication Settings ",
"requestTimeoutMs": "Request Timeout (ms)",
"researchDefaults": "Research Defaults",
"searXNG": "SearXNG",
"searXNGURL": "SearXNG URL",
"searchProvider": "Search Provider",
"searchesAndFetchesUseTheAgentsNativeWebSearch": " Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. ",
"tavily": "Tavily",
"webSearch": " Web Search "
},
"researchProject": {
"alwaysOn": "Always on",
"enableResearchInThisProject": " Enable research in this project ",
"enabledSources": "Enabled Sources",
"github": "GitHub",
"llmSynthesis": "LLM Synthesis",
"localDocs": "Local Docs",
"maxConcurrentRuns": "Max Concurrent Runs",
"maxDurationMs": "Max Duration (ms)",
"maxSourcesPerRun": "Max Sources Per Run",
"pageFetch": "Page Fetch",
"projectResearchSettings": "Project Research Settings",
"requestTimeoutMs": "Request Timeout (ms)",
"webSearch": " Web Search ",
"webSearchIsAlwaysEnabledConfigureTheSearch": " Web search is always enabled. Configure the search provider under Research Defaults. "
},
"resolveAllLocal": "Resolve All: Keep Local",
"resolveAllRemote": "Resolve All: Keep Remote",
"resolveFailed": "Failed to resolve conflicts",
"resolvedSuccess": "Settings conflicts resolved successfully",
"resolving": "Resolving...",
"runtimesRuntimes": {
"hermesRuntime": "Hermes Runtime",
"openClawRuntime": "OpenClaw Runtime",
"paperclipRuntime": "Paperclip Runtime"
},
"scheduledEvals": {
"autoCreateTasks": "Auto-create tasks",
"disabled": "Disabled",
"enableScheduledEvalRunsForThisProject": " Enable scheduled eval runs for this project ",
"evaluatorModel": "Evaluator Model",
"evaluatorProvider": "Evaluator Provider",
"followUpPolicy": "Follow-up Policy",
"gpt5": "gpt-5",
"intervalMs": "Interval (ms)",
"leaveProviderAndModelBlankToInheritThe": " Leave provider and model blank to inherit the project validator lane model settings. ",
"openai": "openai",
"retentionDays": "Retention (days)",
"scheduledEvals": "Scheduled Evals",
"suggestOnly": "Suggest only"
},
"scheduling": {
"addIgnoredPath": " Add ignored path ",
"archiveAgentLog": "Archive Agent Log",
"archiveCompletedTasksAfterDays": "Archive Completed Tasks After (days)",
"backlogNoTaskAutoClaimIsExecutorOnly": "Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.",
"browse": " Browse ",
"closeParenPeriod": ").",
"compactModeKeepsArchiveSizeLowWhilePreserving": "Compact mode keeps archive size low while preserving recent agent activity for context.",
"compactSummaryAndRecentEntries": "Compact summary and recent entries",
"completedTasksOlderThanTheThresholdAreMoved": "Completed tasks older than the threshold are moved out of the active task database.",
"doNotArchiveAgentLogs": "Do not archive agent logs",
"docs": "docs/",
"enableAutomaticTaskArchiving": " Enable automatic task archiving ",
"enablePlanStalenessEnforcement": " Enable plan staleness enforcement ",
"escalateHighFanOutBlockersOnlyAfterThey": "Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.",
"fullAgentLog": "Full agent log",
"globalMaxConcurrent": "Global Max Concurrent",
"heartbeatScopeDiscipline": "Heartbeat Scope Discipline",
"ignoredOverlapPaths": "Ignored overlap paths",
"letEngineerAgentsAutoClaimBacklogTasks": " Let engineer agents auto-claim backlog tasks ",
"lite": "Lite",
"maxConcurrentTasks": "Max Concurrent Tasks",
"maxStuckRetries": "Max Stuck Retries",
"maxTriageConcurrent": "Max Triage Concurrent",
"maximumAgeInHoursBeforeAPlanIs": "Maximum age in hours before a plan is considered stale. Default: 6 hours.",
"maximumConcurrentAgentsAcrossAllProjects": "Maximum concurrent agents across all projects",
"maximumConcurrentPlanningAgents": "Maximum concurrent planning agents",
"maximumStuckDetectorRetriesBeforeATaskIs": "Maximum stuck-detector retries before a task is marked failed. Default: 6.",
"numberOfDaysATaskCanStayIn": "Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).",
"off": "Off",
"optionalFileOrDirectoryPathsToIgnoreWhen": " Optional file or directory paths to ignore when overlap serialization is enabled. Paths are project-relative (for example ",
"or": " or ",
"pollIntervalMs": "Poll Interval (ms)",
"preserveStepProgressOnStuckTaskRequeue": " Preserve step progress on stuck-task requeue ",
"remove": " Remove ",
"scheduling": "Scheduling",
"selectCurrentDir": "Select current directory",
"selectIgnoredOverlapPath": "Select ignored overlap path"
"selectIgnoredOverlapPath": "Select ignored overlap path",
"serializeTasksWithOverlappingFiles": " Serialize tasks with overlapping files ",
"staleHighFanOutEscalationHours": "Stale High Fan-out Escalation (hours)",
"staleSpecThresholdHours": "Stale Spec Threshold (hours)",
"stepExecution": "Step Execution",
"strictCoordinationFocusedHigherPerTickTokensLite": "Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.",
"strictDefault": "Strict (default)",
"stuckTaskTimeoutMinutes": "Stuck Task Timeout (minutes)",
"timeoutInMinutesForDetectingStuckTasksWhen": "Timeout in minutes for detecting stuck tasks. When a task&apos;s agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.",
"whenEnabledTasksThatModifyTheSameFiles": "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts",
"whenEnabledTasksWithStalePlansPROMPTMd": "When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning",
"whenTheStuckDetectorKillsAndReQueues": "When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled."
},
"scope": {
"globalBanner": "These settings are shared across all your Fusion projects.",
@@ -5651,11 +6285,59 @@ export default interface Resources {
},
"title": "Settings",
"worktrees": {
"allowSilentSiblingBranchRenameDuringExecutorConflicts": " Allow silent sibling branch rename during executor conflicts ",
"alsoRebaseOntoLocalDefaultBranchHEAD": " Also rebase onto local default-branch HEAD ",
"and": " and ",
"andCanHidePriorCommitsFromTheDefault": " and can hide prior commits from the default recovery flow. ",
"autoDetectFusionBinWorktrunkOrPATH": "auto-detect (~/.fusion/bin/worktrunk or $PATH)",
"awaitingApproval": "Awaiting approval — open Approvals to continue.",
"branchCollisionSilentlyForksWorkOntoSiblingBranches": " branch collision silently forks work onto sibling branches like ",
"browse": " Browse ",
"browseWorktreesDirectory": "Browse worktrees directory",
"closeParenPeriod": ").",
"defaultsTo": ". Defaults to ",
"defaultsToWorktreesLeaveEmptyUnlessOverriding": "Defaults to .worktrees — leave empty unless overriding",
"disabledByDefaultOptInWhenEnabledFusion": " Disabled by default (opt-in). When enabled, Fusion shells out to ",
"discouragedThisRestoresTheLegacyBehaviorWhereA": " Discouraged. This restores the legacy behavior where a live ",
"enableWorktrunkAndRequestApprovalToInstallThe": "Enable worktrunk and request approval to install the pinned release.",
"enableWorktrunkIntegration": " Enable worktrunk integration ",
"failAndPauseTheTaskDefault": "Fail and pause the task (default)",
"fallBackToFusionsNativeWorktreeBackend": "Fall back to Fusion's native worktree backend",
"forWorktreeCreateSyncPruneAndRemoveOperations": " for worktree create, sync, prune, and remove operations and follows worktrunk&apos;s directory layout. ",
"inAdditionToTheRemoteRebaseAboveAlso": " In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. ",
"installTheWorktrunkBinaryBelowToEnableThis": "Install the worktrunk binary below to enable this integration.",
"installWorktrunk": "Install worktrunk binary",
"installedAt": " installed at ",
"keepsProgressMovingBySwitchingToFusionApos": " keeps progress moving by switching to Fusion&apos;s built-in worktree backend. ",
"limitsTotalGitWorktreesIncludingInReviewTasks": "Limits total git worktrees including in-review tasks",
"maxWorktrees": "Max Worktrees",
"offByDefaultOptInWhenEnabledCompleted": "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup",
"openApprovals": "Open Approvals",
"optionalLeaveBlankToAutoResolveFusionWill": "Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.",
"optionalSupports": " Optional. Supports ",
"pnpmInstallFrozenLockfile": "pnpm install --frozen-lockfile",
"randomNamesEGSwiftFalcon": "Random names (e.g., swift-falcon)",
"rebaseFromRemoteBeforeMerge": " Rebase from remote before merge ",
"rebaseRemote": "Rebase Remote",
"recycleWorktrees": " Recycle worktrees ",
"selectWorktreesDir": "Select worktrees directory",
"tryAgain": "Try again"
"shellCommandToRunInEachNewWorktree": "Shell command to run in each new worktree after creation",
"stopsOnWorktrunkErrorsForExplicitOperatorRecovery": " stops on worktrunk errors for explicit operator recovery; ",
"taskIDEGFN042": "Task ID (e.g., FN-042)",
"taskTitleEGFixLoginBug": "Task title (e.g., fix-login-bug)",
"tryAgain": "Try again",
"useGitDefault": "Use git default",
"whenEnabledTheMergerFetchesFromTheConfigured": "When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging — catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline.",
"whenUnsetOnlyAffectsNewlyCreatedWorktrees": " when unset. Only affects newly-created worktrees. ",
"whichRemoteToFetchForThePreMerge": " Which remote to fetch for the pre-merge rebase. \"Use git default\" falls back to the remote configured for the default branch (typically ",
"worktreeInitCommand": "Worktree Init Command",
"worktreeNamingStyle": "Worktree Naming Style",
"worktrees": "Worktrees",
"worktreesDirectory": "Worktrees Directory",
"worktrunk": " worktrunk ",
"worktrunkBinaryPath": "Worktrunk binary path",
"worktrunkFailureBehavior": "Worktrunk failure behavior",
"worktrunkIntegration": "Worktrunk integration"
}
},
"setup": {
@@ -7980,6 +8662,14 @@ export default interface Resources {
"notConfigured": "Not configured"
}
},
"settings": {
"nodeRouting": {
"statusConnecting": "Connecting",
"statusError": "Error",
"statusOffline": "Offline",
"statusOnline": "Online"
}
},
"setup": {
"apiKeyFormatError": "{{providerName}} keys should follow this format: {{hint}} (e.g. {{example}})",
"apiKeyLabel": {