) => {
setRemoteBusyAction(label);
try {
await action();
await loadRemoteData();
} catch (err) {
addToast(getErrorMessage(err) || `Failed to ${label}`, "error");
} finally {
setRemoteBusyAction(null);
}
}, [addToast, loadRemoteData]);
const cloudflaredManualInstallCommand = useCallback(() => {
if (typeof navigator !== "undefined" && navigator.userAgent.includes("Windows")) {
return "winget install Cloudflare.cloudflared";
}
const platform = typeof navigator !== "undefined" ? navigator.platform : "";
const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
const isMac = /(Mac|iPhone|iPad|iPod)/i.test(platform);
const isArm = /(arm64|aarch64)/i.test(`${platform} ${userAgent}`);
if (isMac) {
return "brew install cloudflared";
}
const linuxArch = isArm ? "arm64" : "amd64";
return `curl -L --output /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${linuxArch} && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/cloudflared # If sudo is unavailable, use: mkdir -p ~/.local/bin && mv /tmp/cloudflared ~/.local/bin/cloudflared`;
}, []);
const cloudflaredMacFallbackCommand = useCallback(() => {
if (typeof navigator === "undefined") {
return null;
}
if (!/(Mac|iPhone|iPad|iPod)/i.test(navigator.platform)) {
return null;
}
const arch = /(arm64|aarch64)/i.test(`${navigator.platform} ${navigator.userAgent}`) ? "arm64" : "amd64";
return `curl -L --output /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${arch} && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/cloudflared`;
}, []);
const handleInstallCloudflared = useCallback(async () => {
setCloudflaredInstalling(true);
setCloudflaredInstallError(null);
try {
const result = await installCloudflared(projectId);
if (!result.success) {
setCloudflaredInstallError(result.error ?? "Installation failed");
return;
}
const status = await fetchRemoteStatus(projectId);
setRemoteStatus(status);
addToast("cloudflared installed successfully", "success");
} catch (err) {
setCloudflaredInstallError(err instanceof Error ? err.message : "Installation failed");
} finally {
setCloudflaredInstalling(false);
}
}, [addToast, projectId]);
/** Render a scope indicator banner for the current section with theme-aware Lucide icons */
const renderScopeBanner = () => {
if (activeSectionScope === "global") {
return (
These settings are shared across all your Fusion projects.
);
}
if (activeSectionScope === "project") {
return (
These settings only affect this project.
);
}
return null;
};
const renderSectionFields = () => {
switch (activeSection) {
case "general":
return (
<>
{renderScopeBanner()}
General
Task Prefix
{
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);
}
}}
/>
{prefixError && {prefixError} }
{!prefixError && Prefix for new task IDs (e.g. KB, PROJ) }
setForm((f) => ({ ...f, requirePlanApproval: e.target.checked }))
}
/>
Require plan approval
When enabled, AI-generated task specifications require manual approval before moving to Todo
setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))
}
/>
Use ephemeral task-worker agents
When enabled (default), Fusion spawns short-lived executor-FN-XXXX agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued.
Completion Documentation Automation
setForm((f) => ({
...f,
completionDocumentationMode: e.target.value as "off" | "changeset" | "changelog",
}))
}
>
Off
Require changeset (.changeset/*.md)
Require changelog update (existing changelog)
Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow
.changeset workflows, or changelog mode when contributors should update an existing changelog file.
setForm((f) => ({ ...f, showQuickChatFAB: e.target.checked }))
}
/>
Show quick chat button
Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.
Chat history
Auto-cleanup old chats
setForm((f) => ({ ...f, chatAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
Off
7 days
14 days
30 days
60 days
90 days
Delete chat sessions and rooms that have been idle for this many days. Default: Off.
Auto-prune old mail
setForm((f) => ({ ...f, mailAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
Off
7 days
14 days
30 days
60 days
90 days
Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.
Chat Rooms
Recent verbatim room messages
setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined }))
}
/>
Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.
Room compaction fetch limit
setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined }))
}
/>
Upper bound on messages fetched from the room store for compaction consideration. Default: 200.
Room summary max characters
setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined }))
}
/>
Hard cap on the synthesized "Earlier room context" summary block. Default: 3000.
Capacity Risk Banner
setForm((f) => ({ ...f, capacityRiskBannerEnabled: e.target.checked }))
}
/>
Show capacity risk banner
Warn on the board when todo work exceeds the threshold and no idle agents are available.
Todo threshold
setForm((f) => ({
...f,
capacityRiskTodoThreshold:
e.target.value === ""
? 0
: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
}))
}
/>
Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.
GitHub Tracking
Default tracking mode for new tasks
setForm((f) => ({
...f,
githubTrackingEnabledByDefault: e.target.value === "new-tasks",
}))
}
>
Off (default)
On for new tasks
Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal.
Tracking issues use this task's title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models.
{!form.autoSummarizeTitles && !form.useAiMergeCommitSummary && !form.githubTrackingEnabledByDefault
? " Enable summarization in Project Models to configure that model."
: ""}
Project default tracking repo
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.
setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))
}
/>
Search the tracking repo for likely duplicates before opening a new issue
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.
>
);
case "global-general":
return (
<>
{renderScopeBanner()}
General
Global default tracking repo
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
Projects inherit this value when they do not set a project default tracking repo.
setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))
}
/>
Save tool output in agent logs
When disabled, tool rows are still logged but detailed tool payloads are omitted.
Very large tool payloads may still be clipped even when this stays enabled.
Save AI thinking logs
setForm((f) => ({ ...f, persistAgentThinkingLogPermanent: e.target.checked }))
}
/>
Save AI thinking for permanent agents
setForm((f) => ({ ...f, persistAgentThinkingLogEphemeral: e.target.checked }))
}
/>
Save AI thinking for ephemeral / task-worker agents
Leave both thinking toggles off to keep the original default behavior.
This only controls persisted thinking rows and does not affect assistant text or tool rows.
setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))
}
/>
Check for the fn CLI binary on PATH
When enabled, the dashboard probes for a globally-installed{" "}
fn / fusion CLI by spawning{" "}
<bin> --version. Disable this if your local
dev process is the source of truth and you don't want any
outdated globally-installed binary executed during the probe.
Updates
setForm((f) => ({ ...f, updateCheckEnabled: e.target.checked }))
}
/>
Check for updates automatically
When enabled, Fusion checks npm for new versions of{" "}
@runfusion/fusion and shows update notices in the CLI and dashboard.
Cadence is governed by the frequency below.
Frequency
setForm((f) => ({
...f,
updateCheckFrequency: e.target.value as
| "manual"
| "on-startup"
| "daily"
| "weekly",
}))
}
disabled={form.updateCheckEnabled === false}
>
Manual only — never auto-check
On startup — once per server launch
Daily (recommended)
Weekly
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.
setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))
}
/>
Auto-reload dashboard on version change
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.
>
);
case "global-models": {
const selectedValue = form.defaultProvider && form.defaultModelId
? `${form.defaultProvider}/${form.defaultModelId}`
: "";
const globalModelLanes = MODEL_LANES.filter(
(lane) => lane.laneId !== "default",
);
return (
<>
{renderScopeBanner()}
{/* --- Default Model --- */}
Default Model
{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available. Configure authentication first.
) : (
<>
Default Model
{
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),
}));
}
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically.
Fallback Model
{
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={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.
>
)}
{(() => {
const selectedModel = availableModels.find(
(m) => m.provider === form.defaultProvider && m.id === form.defaultModelId,
);
if (selectedModel && !selectedModel.reasoning) return null;
return (
Thinking Effort
{
const val = e.target.value;
setForm((f) => ({ ...f, defaultThinkingLevel: (val as ThinkingLevel) || undefined }));
}}
>
Default
{THINKING_LEVELS.map((level) => (
{level.charAt(0).toUpperCase() + level.slice(1)}
))}
Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.
);
})()}
{availableModels.length > 0 && (
<>
Model Lanes
Global baseline models for each AI role. Project settings can override these per-project.
{globalModelLanes.map((lane) => {
const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined;
const model = form[lane.globalModelKey as keyof Settings] as string | undefined;
const value = provider && model ? `${provider}/${model}` : "";
return (
{lane.label}
{
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="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
{lane.helperText}
);
})}
>
)}
{/* --- Startup Model Sync --- */}
Startup Model Sync
setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}
/>
Sync OpenRouter model list at startup
When enabled, startup fetches the latest available models from the OpenRouter API so
model pickers always include the newest catalog.
setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}
/>
Sync opencode-go model list at startup
When enabled, startup refreshes models through the local opencode models opencode --refresh
flow and publishes them under the opencode-go provider in model pickers.
OpenRouter advanced
OpenRouter HTTP-Referer
setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
referer: e.target.value,
},
}))}
/>
Leave empty to omit this header. Default: https://runfusion.ai.
OpenRouter X-Title
setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
title: e.target.value,
},
}))}
/>
Leave empty to omit this header. Default: Fusion.
OpenRouter supported_parameters filter
{
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterModelFilters: {
...(f.openrouterModelFilters || {}),
supported_parameters: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
Comma-separated values sent to OpenRouter model sync.
OpenRouter output_modalities filter
{
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterModelFilters: {
...(f.openrouterModelFilters || {}),
output_modalities: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
Comma-separated values sent to OpenRouter model sync.
OpenRouter routing order
{
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
order: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
OpenRouter routing ignore
{
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
ignore: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
OpenRouter routing only
{
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
only: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
OpenRouter allow fallbacks
{
const value = e.target.value;
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
allow_fallbacks: value === "default" ? undefined : value === "allow",
},
}));
}}
>
default
allow
deny
OpenRouter routing sort
{
const value = e.target.value;
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
sort: value === "default" ? undefined : value as "price" | "throughput" | "latency",
},
}));
}}
>
default
price
throughput
latency
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
require_parameters: e.target.checked,
},
}))}
/>
Require parameters
>
);
}
case "secrets":
return (
<>
{renderScopeBanner()}
Secrets
>
);
case "project-models": {
const presets = form.modelPresets || [];
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean));
// Filter model lanes to show in project scope.
// The "summarization" lane is intentionally excluded here — it has a
// dedicated picker further down ("AI Title and Git Commit Message
// Summarization") so the project tab doesn't surface the same model
// setting twice.
const projectModelLanes = MODEL_LANES.filter(
(lane) =>
lane.laneId === "default"
|| lane.laneId === "execution"
|| lane.laneId === "planning"
|| lane.laneId === "validator",
);
const resolvedPlanningModel = resolvePlanningSettingsModel(form);
const resolvedDefaultModel = resolveProjectDefaultModel(form);
const resolvedTitleSummarizerModel = resolveTitleSummarizerSettingsModel(form);
const getProjectLaneLabel = (lane: ModelLane) => lane.laneId === "default" ? "Project Default Model" : lane.label;
const getProjectLaneHelperText = (lane: ModelLane) =>
lane.laneId === "default"
? "Project-wide default AI model used when no more specific task or project lane override is set."
: lane.helperText;
return (
<>
{renderScopeBanner()}
{/* --- Token Cap --- */}
Token Cap
{/* --- Project Model Lanes --- */}
Model Lanes
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.
{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available. Configure authentication first.
) : (
<>
{projectModelLanes.map((lane) => {
const status = getLaneStatus(lane);
const value = getLaneValue(lane);
const isOverridden = status === "overridden";
const laneLabel = getProjectLaneLabel(lane);
return (
{laneLabel}
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
updateLaneValue(lane, val)}
placeholder={lane.laneId === "default" ? "Use global default" : "Use global"}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
{isOverridden && (
resetLaneValue(lane)}
style={{ whiteSpace: "nowrap" }}
>
Reset
)}
{getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}.
);
})}
>
)}
{/* --- Fallback Models --- */}
Fallback Models
{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available.
) : (
<>
Planning Fallback Model
{
if (!val) {
setForm((f) => ({ ...f, planningFallbackProvider: undefined, planningFallbackModelId: undefined }));
} else {
const slashIdx = val.indexOf("/");
setForm((f) => ({
...f,
planningFallbackProvider: val.slice(0, slashIdx),
planningFallbackModelId: val.slice(slashIdx + 1),
}));
}
}}
placeholder="Use global fallback"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
Used if the planning model fails due to rate limits or provider overload. Defaults to the global fallback model.
Reviewer Fallback Model
{
if (!val) {
setForm((f) => ({ ...f, validatorFallbackProvider: undefined, validatorFallbackModelId: undefined }));
} else {
const slashIdx = val.indexOf("/");
setForm((f) => ({
...f,
validatorFallbackProvider: val.slice(0, slashIdx),
validatorFallbackModelId: val.slice(slashIdx + 1),
}));
}
}}
placeholder="Use global fallback"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
Used if the reviewer model fails due to rate limits or provider overload. Defaults to the global fallback model.
>
)}
{/* --- Model Presets --- */}
Model Presets
Configured presets
{presets.length === 0 ? (
No presets configured yet.
) : (
{presets.map((preset) => {
const selection = applyPresetToSelection(preset);
const summary = `${selection.executorValue || "default"} / ${selection.validatorValue || "default"}`;
return (
{preset.name}
{summary}
{
setEditingPresetId(preset.id);
setPresetDraft({ ...preset });
}}
>
Edit
{
if (inUsePresetIds.has(preset.id)) {
const shouldDelete = await confirm({
title: "Delete Preset",
message: `Preset "${preset.name}" is used in auto-selection. Delete it anyway?`,
danger: true,
});
if (!shouldDelete) {
return;
}
}
setForm((current) => ({
...current,
modelPresets: (current.modelPresets || []).filter((entry) => entry.id !== preset.id),
defaultPresetBySize: Object.fromEntries(
Object.entries(current.defaultPresetBySize || {}).filter(([, value]) => value !== preset.id),
) as Settings["defaultPresetBySize"],
}));
if (editingPresetId === preset.id) {
setEditingPresetId(null);
setPresetDraft(null);
}
}}
>
Delete
);
})}
)}
{!presetDraft ? (
{
setEditingPresetId(null);
setPresetDraft({ id: "", name: "", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined });
}}
>
Add Preset
) : null}
{presetDraft ? (
Preset editor
Name
{
const name = e.target.value;
setPresetDraft((current) => current ? { ...current, name } : current);
}}
/>
{availableModels.length === 0 ? (
No models available. Configure authentication first.
) : (
<>
Executor model
{
if (!val) {
setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined } : current);
return;
}
const slashIdx = val.indexOf("/");
setPresetDraft((current) => current ? {
...current,
executorProvider: val.slice(0, slashIdx),
executorModelId: val.slice(slashIdx + 1),
} : current);
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
Reviewer model
{
if (!val) {
setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined } : current);
return;
}
const slashIdx = val.indexOf("/");
setPresetDraft((current) => current ? {
...current,
validatorProvider: val.slice(0, slashIdx),
validatorModelId: val.slice(slashIdx + 1),
} : current);
}}
placeholder="Use default"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
>
)}
Save preset
{ setEditingPresetId(null); setPresetDraft(null); }}>Cancel
) : null}
setForm((current) => ({ ...current, autoSelectModelPreset: e.target.checked }))}
/>
Auto-select preset based on task size
{form.autoSelectModelPreset ? (
{(["S", "M", "L"] as const).map((sizeKey) => (
{sizeKey === "S" ? "Small tasks (S):" : sizeKey === "M" ? "Medium tasks (M):" : "Large tasks (L):"}
{
const value = e.target.value || undefined;
setForm((current) => ({
...current,
defaultPresetBySize: {
...(current.defaultPresetBySize || {}),
[sizeKey]: value,
},
}));
}}
>
No preset
{presetOptions.map((preset) => (
{preset.name}
))}
))}
) : null}
{/* --- AI Title and Git Commit Message Summarization --- */}
AI Title and Git Commit Message Summarization
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.
setForm((f) => ({ ...f, autoSummarizeTitles: e.target.checked }))}
/>
Auto-summarize long descriptions as titles
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.
setForm((f) => ({ ...f, useAiMergeCommitSummary: e.target.checked }))}
/>
AI merge commit summaries
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.
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (
<>
Title, commit message, and GitHub tracking issue summarization model
{modelsLoading ? (
Loading available models...
) : availableModels.length === 0 ? (
No models available. Configure authentication first.
) : (
{
if (!val) {
setForm((f) => ({
...f,
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
}));
return;
}
const slashIdx = val.indexOf("/");
setForm((f) => ({
...f,
titleSummarizerProvider: val.slice(0, slashIdx),
titleSummarizerModelId: val.slice(slashIdx + 1),
}));
}}
placeholder="Use fallback model"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
Also used to summarize task descriptions into GitHub tracking issue titles when a task has no title yet.
{form.titleSummarizerProvider && form.titleSummarizerModelId
? "Using explicitly configured model"
: resolvedTitleSummarizerModel.provider && resolvedTitleSummarizerModel.modelId
? resolvedTitleSummarizerModel.provider === resolvedPlanningModel.provider
&& resolvedTitleSummarizerModel.modelId === resolvedPlanningModel.modelId
? "(using planning model)"
: resolvedTitleSummarizerModel.provider === resolvedDefaultModel.provider
&& resolvedTitleSummarizerModel.modelId === resolvedDefaultModel.modelId
? form.defaultProviderOverride && form.defaultModelIdOverride
? "(using project default model)"
: "(using global default model)"
: "(using global summarization model)"
: "(using automatic model selection)"}
setForm((f) => ({
...f,
titleSummarizerProvider: resolvedPlanningModel.provider,
titleSummarizerModelId: resolvedPlanningModel.modelId,
}))
}
disabled={!resolvedPlanningModel.provider || !resolvedPlanningModel.modelId}
>
Use planning model
setForm((f) => ({
...f,
titleSummarizerProvider: resolvedDefaultModel.provider,
titleSummarizerModelId: resolvedDefaultModel.modelId,
}))
}
disabled={!resolvedDefaultModel.provider || !resolvedDefaultModel.modelId}
>
Use default model
>
)}
>
);
}
case "appearance":
return (
<>
{renderScopeBanner()}
Appearance
{
setForm((f) => ({ ...f, themeMode: mode }));
onThemeModeChange?.(mode);
}}
onColorThemeChange={(theme) => {
setForm((f) => ({ ...f, colorTheme: theme }));
onColorThemeChange?.(theme);
}}
onDashboardFontScaleChange={(scalePct) => {
setForm((f) => ({ ...f, dashboardFontScalePct: scalePct }));
onDashboardFontScaleChange?.(scalePct);
}}
/>
setSessionBannersHidden(e.target.checked)}
/>
Hide AI session notification banners
Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed.
>
);
case "scheduling":
return (
<>
{renderScopeBanner()}
Scheduling
Global Max Concurrent
{
const val = e.target.value;
globalConcurrencyDirtyRef.current = true;
setGlobalMaxConcurrent(val === "" ? undefined : Number(val));
}}
/>
Maximum concurrent agents across all projects
Max Concurrent Tasks
{
const val = e.target.value;
setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
Max Triage Concurrent
{
const val = e.target.value;
setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
Maximum concurrent planning agents
Poll Interval (ms)
{
const val = e.target.value;
setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
Heartbeat Scope Discipline
{
setForm((f) => ({
...f,
heartbeatScopeDiscipline: e.target.value as "strict" | "lite" | "off",
}));
}}
>
Strict (default)
Lite
Off
Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.
Stuck Task Timeout (minutes)
{
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
}}
/>
Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.
Stale High Fan-out Escalation (hours)
{
const val = e.target.value;
const num = Number(val);
setForm((f) => ({
...f,
staleHighFanoutBlockerAgeThresholdMs: val && num > 0 ? num * 3600000 : undefined,
}));
}}
/>
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.
setForm((f) => ({ ...f, preserveProgressOnStuckRequeue: e.target.checked }))
}
/>
Preserve step progress on stuck-task requeue
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.
setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))
}
/>
Enable plan staleness enforcement
When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning
Stale Spec Threshold (hours)
{
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined }));
}}
disabled={!form.specStalenessEnabled}
/>
Maximum age in hours before a plan is considered stale. Default: 6 hours.
setForm((f) => ({
...f,
autoArchiveDoneTasksEnabled: e.target.checked,
}))
}
/>
Enable automatic task archiving
Completed tasks older than the threshold are moved out of the active task database.
Archive Completed Tasks After (days)
{
const val = e.target.value;
const num = Number(val);
setForm((f) => ({
...f,
autoArchiveDoneAfterMs: val === "" ? undefined : num * MS_PER_DAY,
}));
}}
disabled={form.autoArchiveDoneTasksEnabled === false}
/>
Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).
Archive Agent Log
setForm((f) => ({
...f,
archiveAgentLogMode: e.target.value as "none" | "compact" | "full",
}))
}
disabled={form.autoArchiveDoneTasksEnabled === false}
>
Compact summary and recent entries
Do not archive agent logs
Full agent log
Compact mode keeps archive size low while preserving recent agent activity for context.
Max Stuck Retries
{
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, maxStuckKills: val && num > 0 ? num : undefined }));
}}
/>
Maximum stuck-detector retries before a task is marked failed. Default: 6.
setForm((f) => ({ ...f, groupOverlappingFiles: e.target.checked }))
}
/>
Serialize tasks with overlapping files
When enabled, tasks that modify the same files are queued serially to avoid merge conflicts
Ignored overlap paths
Optional file or directory paths to ignore when overlap serialization is enabled.
Paths are project-relative (for example docs/ or generated/*).
{(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => (
))}
Add ignored path
Step Execution
setForm((f) => ({ ...f, runStepsInNewSessions: e.target.checked }))
}
/>
Run each step in a new session
Run each task step in its own fresh agent session for better isolation and error recovery. Failed steps can be retried individually.
Maximum parallel steps
{
const val = e.target.value;
setForm((f) => ({ ...f, maxParallelSteps: val === "" ? undefined : Number(val) }));
}}
disabled={!form.runStepsInNewSessions}
/>
Maximum number of steps to run in parallel when file scopes don't overlap (1-4)
>
);
case "scheduled-evals": {
const evalSettings = form.evalSettings ?? {};
const isScheduledEvalEnabled = evalSettings.enabled ?? false;
return (
<>
{renderScopeBanner()}
Scheduled Evals
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable scheduled eval runs for this project
Interval (ms)
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
intervalMs: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
Evaluator Provider
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="openai"
/>
Evaluator Model
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorModelId: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="gpt-5"
/>
Leave provider and model blank to inherit the project validator lane model settings.
Follow-up Policy
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
followUpPolicy: event.target.value as "disabled" | "suggest-only" | "auto-create",
},
}))
}
>
Disabled
Suggest only
Auto-create tasks
Retention (days)
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
retentionDays: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
>
);
}
case "node-routing":
return (
<>
{renderScopeBanner()}
Node Routing
Configure how tasks are routed to execution nodes.
These settings apply at the project level.
Default Execution Node
{
const val = e.target.value;
setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState));
}}
>
Local execution (no default node)
{nodes.map((node) => (
{node.name} ({getNodeStatusLabel(node.status)})
))}
{(() => {
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
if (!selectedNode) return null;
return (
Selected node:
);
})()}
Used when a task has no node override. Node status is shown for safer routing selection.
Unavailable Node Policy
setForm((f) => ({
...f,
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
} as SettingsFormState))
}
>
Block execution
Fall back to local
>
);
case "worktrees":
return (
<>
{renderScopeBanner()}
Worktrees
Max Worktrees
{
const val = e.target.value;
setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
Limits total git worktrees including in-review tasks
Worktree Init Command
setForm((f) => ({ ...f, worktreeInitCommand: e.target.value }))
}
/>
Shell command to run in each new worktree after creation
setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))
}
/>
Recycle worktrees
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
setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))
}
/>
Allow silent sibling branch rename during executor conflicts
Discouraged. This restores the legacy behavior where a live fusion/<task-id> branch collision silently forks work onto sibling branches like -2 and can hide prior commits from the default recovery flow.
Worktree Naming Style
setForm((f) => ({ ...f, worktreeNaming: e.target.value as "random" | "task-id" | "task-title" }))
}
disabled={form.recycleWorktrees}
>
Random names (e.g., swift-falcon)
Task ID (e.g., FN-042)
Task title (e.g., fix-login-bug)
{form.recycleWorktrees
? "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names"
: "How to name fresh worktree directories. Only applies when recycling is off."}
setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: e.target.checked }))
}
/>
Rebase from remote before merge
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.
{form.worktreeRebaseBeforeMerge !== false && (
Rebase Remote
setForm((f) => ({ ...f, worktreeRebaseRemote: e.target.value || undefined }))
}
>
Use git default
{gitRemotes.map((remote) => (
{remote.name} ({remote.fetchUrl})
))}
Which remote to fetch for the pre-merge rebase. "Use git default" falls back to the remote configured for the default branch (typically origin).
)}
setForm((f) => ({ ...f, worktreeRebaseLocalBase: e.target.checked }))
}
/>
Also rebase onto local default-branch HEAD
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.
Worktrunk integration
setForm((f) => ({
...f,
worktrunk: {
enabled: e.target.checked,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))
}
/>
Enable worktrunk integration
Disabled by default (opt-in). When enabled, Fusion shells out to worktrunk for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout.
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (
Install the worktrunk binary below to enable this integration.
)}
{worktrunkInstall.status === "installed" && (
worktrunk {worktrunkInstall.version ?? ""} installed at {worktrunkInstall.installPath ?? "~/.fusion/bin/worktrunk"}
)}
{(worktrunkInstall.status === "missing" || worktrunkInstall.status === "installing") && (
<>
void worktrunkInstall.requestInstall()}
disabled={worktrunkInstall.requesting || worktrunkInstall.status === "installing"}
>
Install worktrunk binary
Enable worktrunk and request approval to install the pinned release.
>
)}
{worktrunkInstall.status === "pending-approval" && (
<>
Awaiting approval — open Approvals to continue.
onOpenApprovals?.(worktrunkInstall.pendingApprovalId)}
>
Open Approvals
>
)}
{(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && (
<>
{worktrunkInstall.error ?? "Worktrunk install failed."}
void worktrunkInstall.requestInstall()}>
Try again
>
)}
Worktrunk binary path
setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: e.target.value,
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))
}
/>
Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.
Worktrunk failure behavior
setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: e.target.value as "fail" | "fallback-native",
},
}))
}
>
Fail and pause the task (default)
Fall back to Fusion's native worktree backend
fail stops on worktrunk errors for explicit operator recovery; fallback-native keeps progress moving by switching to Fusion's built-in worktree backend.
>
);
case "commands":
return (
<>
{renderScopeBanner()}
Commands
Test Command
setForm((f) => ({ ...f, testCommand: e.target.value || undefined }))
}
/>
Command used to run tests — injected into generated task specs
Build Command
setForm((f) => ({ ...f, buildCommand: e.target.value || undefined }))
}
/>
Command used to build the project — injected into generated task specs
>
);
case "merge":
return (
<>
{renderScopeBanner()}
Merge
setForm((f) => ({ ...f, autoMerge: e.target.checked }))
}
/>
Auto-merge completed tasks
More details
When enabled, tasks that pass review are automatically merged into the main branch
AI merge
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))
}
>
AI merge (default) — AI merges in a clean room, an AI reviewer audits with retries, then lands
Deterministic (legacy) — rebase / conflict-strategy / audit pipeline
More details
AI mode merges the task branch into an isolated clean-room checkout at the target
branch's tip, has an AI reviewer audit the squash (with corrective retries —
advisory concerns land with a logged warning, an unfixable correctness concern
hard-fails), then fast-forwards the target branch and syncs your local checkout
(AI reconciles a conflicting restore). Each task merges to its own target branch,
or the default integration branch. The legacy merge settings below do not
apply while AI merge is on.
{(form.merger?.mode ?? "ai") === "ai" && (
<>
Max AI review passes
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))
}
/>
AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model.
setForm((f) => ({
...f,
merger: { ...(f.merger ?? {}), allowDirtyLocalCheckoutSync: e.target.checked },
}))
}
/>
Allow AI merge to sync a dirty checked-out integration branch
More details
Dangerous compatibility escape hatch. Leave off unless you explicitly want the legacy
stash → fast-forward → restore behavior when your checked-out integration branch has
unrelated local edits. When off, AI merge blocks before advancing the branch so dirty
project-root edits cannot contaminate a completed merge.
>
)}
setForm((f) => ({ ...f, testMode: e.target.checked }))
}
/>
Enable test mode
More details
Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.
setForm((f) => ({ ...f, workflowRevisionForkOnScopeMismatch: e.target.checked }))
}
/>
Fork scope-mismatched workflow revisions into follow-up tasks
More details
When enabled, workflow revision feedback that explicitly names files outside the original task's declared File Scope is split into a dependent follow-up task instead of being appended to the current task's PROMPT.md.
Verification auto-fix retries
{
const rawValue = e.target.value;
if (rawValue === "") {
setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState));
return;
}
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue)) {
setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState));
return;
}
const clampedValue = Math.max(0, Math.min(3, parsedValue));
setForm((f) => ({ ...f, verificationFixRetries: clampedValue } as SettingsFormState));
}}
/>
More details
Controls auto-fix retry attempts after deterministic test/build verification failures — applies to both executor-time and in-merge verification (0-3).
Auto-completion mode
setForm((f) => ({ ...f, mergeStrategy: e.target.value as Settings["mergeStrategy"] }))
}
>
Direct merge into the current branch
Create, monitor, and merge a GitHub pull request
More details
Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR.
Integration branch
{(() => {
const currentValue = form.integrationBranch ?? "";
const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue);
const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown);
if (isCustomMode) {
return (
{
const trimmed = e.target.value.trim();
setForm((f) => ({
...f,
integrationBranch: trimmed.length === 0 ? undefined : trimmed,
}));
}}
data-testid="integration-branch-custom-input"
/>
{
setIntegrationBranchCustomMode(false);
setForm((f) => ({ ...f, integrationBranch: undefined }));
}}
data-testid="integration-branch-use-dropdown"
>
Use dropdown
);
}
const CUSTOM = "__fusion-custom__";
const AUTO = "";
return (
{
const next = e.target.value;
if (next === CUSTOM) {
setIntegrationBranchCustomMode(true);
return;
}
setForm((f) => ({
...f,
integrationBranch: next === AUTO ? undefined : next,
}));
}}
data-testid="integration-branch-select"
>
(auto-detect — origin/HEAD → main)
{integrationBranchOptions.map((name) => (
{name}
))}
Custom…
);
})()}
More details
The canonical branch Fusion merges tasks into and uses as the reference for all
ahead/behind / overlap / pre-rebase computations. Leave on auto-detect
to resolve via the standard cascade
(integrationBranch → legacy baseBranch →
origin/HEAD symbolic ref → fallback main). Pick a
local branch from the dropdown — common integration names like main,
master, trunk, and develop are listed
first — or choose Custom… to type a branch that doesn't exist
locally yet. Applies to both direct merges and pull-request mode; individual
tasks can still override via task metadata.
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (
<>
Direct merge commit routing
setForm((f) => ({
...f,
directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase",
}))
}
>
Auto — squash single-substantive branches, preserve multi-substantive history
Always squash direct merges
Always preserve direct-merge commit history
More details
Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with **Direct Merge Commit Strategy:** auto|always-squash|always-rebase.
Integration worktree
setForm((f) => ({
...f,
mergeIntegrationWorktree: e.target.value as Settings["mergeIntegrationWorktree"],
}))
}
>
Reuse task worktree (default)
Use project root (legacy)
Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk.
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (
Legacy integration-branch mode. {" "}
Auto-merge will run rebase, conflict resolution, and squash commits inside the
project root (the user's checked-out integration-branch worktree) instead of
the task worktree. Fusion assumes that directory is already on the integration
branch and clean; if it isn't, merges may fail or touch the user's working
tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless
you have a specific reason to opt in (FN-5348).
)}
Auto-sync project checkout after merge
setForm((f) => ({
...f,
mergeAdvanceAutoSync: e.target.value as "off" | "ff-only" | "stash-and-ff",
}))
}
data-testid="merge-advance-auto-sync-select"
>
Stash + fast-forward (default) — preserve local edits
Fast-forward only — skip dirty worktrees
Off — leave the project root stale (legacy behavior)
More details
After Fusion advances the integration branch ref, the merger can auto-sync other
worktrees still checked out on that branch (typically your project-root
checkout). Stash + fast-forward snapshots real local edits as a patch
against the previous tip, snaps the worktree to the new tip, then reapplies the
patch — untracked files that collide with newly-tracked paths are left in a temp
dir for manual recovery. Fast-forward only snaps cleanly when the
worktree has no edits and skips otherwise. Off is the legacy
behavior: git status in your project root will show the new commits
inverted as "staged changes" until you pull manually. Only applies to direct
merges.
>
)}
{form.mergeStrategy === "pull-request" && (
setForm((f) => ({ ...f, requirePrApproval: e.target.checked }))
}
/>
Wait for an approving review before merging the PR
More details
When enabled, Fusion holds the PR in In Review until at least one approving GitHub review has been submitted. Useful on free private repos where GitHub's required-reviewer enforcement isn't available — without this, a fresh PR with no required checks is treated as immediately mergeable.
)}
GitHub Authentication
GitHub auth mode
setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))
}
>
GitHub CLI (gh auth)
Personal access token
{(form.githubAuthMode ?? "gh-cli") === "token" && (
GitHub personal access token
setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))
}
/>
)}
setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))
}
/>
Include task ID in commit scope
More details
When disabled, merge commit messages omit the task ID from the scope (e.g. feat: ... instead of feat(KB-001): ...)
setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))
}
/>
Add Fusion as co-author on commits
More details
When enabled, commits made by Fusion keep your git identity as the
primary author and append a Co-authored-by trailer crediting
Fusion (recognized by GitHub for shared attribution).
{form.commitAuthorEnabled !== false && (
<>
Co-author Name
setForm((f) => ({
...f,
commitAuthorName: e.target.value || undefined,
}))
}
/>
Name used in the Co-authored-by trailer
Co-author Email
setForm((f) => ({
...f,
commitAuthorEmail: e.target.value || undefined,
}))
}
/>
Email used in the Co-authored-by trailer
>
)}
setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))
}
/>
Auto-resolve conflicts in lock files and generated files
More details
When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review.
{(form.merger?.mode ?? "ai") !== "ai" && (
<>
setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))
}
/>
Smart conflict resolution
More details
When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.
Conflict Fallback Strategy
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))
}
>
Smart, prefer main on fallback — fetch+ff origin → AI → auto-resolve → -X ours (default; protects just-merged sibling work)
Smart, prefer task on fallback — fetch+ff origin → AI → auto-resolve → -X theirs (legacy "smart" behavior; task branch wins)
AI only — AI → auto-resolve → AI retry; never silently pick a side
Abort — one AI attempt; require manual resolution if it fails
More details
Both Smart options start with a best-effort git fetch + fast-forward of local main from origin (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the final fallback :
{" "}
Smart, prefer main uses -X ours so main wins — protects just-merged sibling work and is the new default.
{" "}
Smart, prefer task uses -X theirs so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression).
{" "}
AI only retries the AI agent rather than auto-picking a side.
{" "}
Abort stops after the first AI attempt and waits for a human.
{" "}
Legacy "smart" and "prefer-main" values from older settings are migrated automatically.
Smart Prefer Main Overlap Guard
setForm((f) => ({
...f,
mergeStrategyOverlapBehavior: e.target.value as "flip-to-prefer-branch" | "warn-only" | "ignore",
}))
}
>
Flip overlapping files to prefer the task branch (default)
Warn only — keep legacy main-wins fallback
Ignore overlap detection — preserve legacy behavior
When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work.
Post-merge audit mode
setForm((f) => ({
...f,
postMergeAuditMode: e.target.value as "block" | "warn" | "off",
}))
}
>
Block (strict)
Warn (default; log findings, continue)
Off (skip audit)
Controls the post-merge audit gate. Warn (default) logs findings but auto-completes the merge. Block is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. Off skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits.
>
)}
setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))
}
/>
Push to remote after merge
More details
When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed.
{form.pushAfterMerge && (
Push Remote
setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))
}
/>
More details
Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin".
)}
>
);
case "agent-permissions":
return (
<>
{renderScopeBanner()}
Agent Permissions
Per-agent settings override project defaults. Each category controls a separate approval gate.
setForm((f) => ({
...f,
defaultAgentPermissionPolicy: { rules: toCompleteAgentPermissionRules(next?.rules) },
}))
}
/>
Agent Provisioning Approvals
Configure project-level approval behavior for durable provisioning tools (fn_agent_create/fn_agent_delete).
setForm((f) => ({ ...f, agentProvisioning: next }))}
/>
>
);
case "memory": {
// Use memory backend status from top-level hook call
const {
capabilities,
status: backendStatus,
loading: backendLoading,
error: backendError,
} = {
capabilities: memoryCapabilities,
status: memoryBackendStatus,
loading: memoryBackendLoading,
error: memoryBackendError,
};
// Determine if editing is allowed
const isMemoryEnabled = form.memoryEnabled !== false;
const backendStatusResolved = !backendLoading && backendStatus !== null;
const isBackendWritable = backendStatusResolved ? (capabilities?.writable ?? true) : true;
const isEditingAllowed = isMemoryEnabled && isBackendWritable;
const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath);
const memoryLayerNames: Record = {
"long-term": "Long-term",
daily: "Daily",
dreams: "Dreams",
};
return (
<>
{renderScopeBanner()}
Memory
Memory lives in .fusion/memory/. Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed.
setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))
}
/>
Enable memory tools
Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.
{backendLoading ? (
Checking memory write access...
) : backendError ? (
Failed to load backend status: {backendError}
) : null}
{backendStatusResolved && backendStatus.qmdAvailable === false && (
qmd is not installed. Search will use local files.
Install indexed retrieval: {backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}
{qmdInstallLoading ? "Installing…" : "Install qmd"}
)}
setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))
}
/>
Auto-Summarize Memory
Automatically compact memory when it exceeds the threshold on a schedule
{(form.memoryAutoSummarizeEnabled || false) && (
<>
Compaction Threshold (chars)
setForm((f) => ({
...f,
memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000,
}))
}
min={1000}
/>
Memory will be compacted when it exceeds this character count
Schedule (cron)
setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))
}
placeholder="0 3 * * *"
/>
Cron expression for auto-summarize schedule (default: daily at 3 AM)
>
)}
setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))
}
disabled={!isMemoryEnabled}
/>
Process dreams from daily memory
Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
<>
Dream Schedule
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
}
/>
Cron expression for dream processing.
{dreamRunning ? (
<>
Dreaming…
>
) : (
"Dream Now"
)}
Manually trigger dream processing now.
>
)}
Test Retrieval
setMemoryTestQuery(e.target.value)}
placeholder="Search memory with qmd"
/>
Runs the same qmd-backed memory_search path agents use.
{memoryTestLoading ? "Testing…" : "Test Retrieval"}
{memoryTestResult && (
{memoryTestResult.results.length} result{memoryTestResult.results.length === 1 ? "" : "s"}
{" "}for "{memoryTestResult.query}"
qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"}
{memoryTestResult.results.length > 0 ? (
{memoryTestResult.results.map((result, index) => (
{result.path}:{result.lineStart}
{result.snippet}
))}
) : (
No matching memory found.
)}
)}
{!isMemoryEnabled && (
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
)}
{isMemoryEnabled && backendStatusResolved && !isBackendWritable && (
Memory is configured with a read-only backend. You can view the file, but saving is disabled.
)}
{memoryLoading ? (
Loading memory…
) : (
Memory File
{
setSelectedMemoryPath(e.target.value);
setMemoryDirty(false);
}}
disabled={memoryDirty}
>
{memoryFiles.map((file) => (
{formatMemoryFileOptionLabel(file)}
))}
{memoryDirty
? "Save or discard the current edits before switching files."
: "Choose any project memory file to view or edit. Dreams is selected by default."}
{selectedMemoryFile && (
{memoryLayerNames[selectedMemoryFile.layer]}
{selectedMemoryFile.path}
{selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()}
)}
{selectedMemoryFile?.label || "Memory Editor"}
{selectedMemoryFile?.layer === "long-term" && "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams."}
{selectedMemoryFile?.layer === "daily" && "Raw daily observations, open loops, and running context for dream processing."}
{selectedMemoryFile?.layer === "dreams" && "Synthesized patterns and open loops promoted from daily memory."}
{!selectedMemoryFile && "Edits the selected memory file."}
{
setMemoryContent(content);
setMemoryDirty(true);
}}
readOnly={!isEditingAllowed}
filePath={selectedMemoryPath}
/>
)}
{!memoryLoading && (
{memoryCompactLoading ? "Compacting…" : "Compact Selected File"}
{memoryDirty
? "Save or discard edits before compacting this file."
: `Compacts ${selectedMemoryPath} and writes the result back to the same file.`}
)}
{memoryDirty && isEditingAllowed && (
Save Memory
)}
{memoryDirty && !isEditingAllowed && (
Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}
)}
>
);
}
case "research-global": {
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 (
<>
{renderScopeBanner()}
Research Defaults
Default Max Concurrent Runs
setForm((current) => ({
...current,
researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
Default Max Sources Per Run
setForm((current) => ({
...current,
researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
Default Max Duration (ms)
setForm((current) => ({
...current,
researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
Request Timeout (ms)
setForm((current) => ({
...current,
researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
Max Synthesis Rounds
setForm((current) => ({
...current,
researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
{hasMissingResearchCredential && (
Missing credentials for the selected research provider.
setActiveSection("authentication")}>
Open Authentication
)}
>
);
}
case "research-project": {
const limits = form.researchSettings?.limits;
const sources = form.researchSettings?.enabledSources;
return (
<>
{renderScopeBanner()}
Project Research Settings
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable research in this project
Enabled Sources
Web Search Always on
Web search is always enabled. Configure the search provider under Research Defaults.
{[
["pageFetch", "Page Fetch"],
["github", "GitHub"],
["localDocs", "Local Docs"],
["llmSynthesis", "LLM Synthesis"],
].map(([key, label]) => (
] ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabledSources: {
...(current.researchSettings?.enabledSources ?? {}),
[key]: event.target.checked,
},
},
}))
}
/>
{label}
))}
Max Concurrent Runs
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
Max Sources Per Run
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
Max Duration (ms)
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
Request Timeout (ms)
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
{researchLimitError &&
{researchLimitError} }
>
);
}
case "experimental": {
const experimentalFeatures = form.experimentalFeatures ?? {};
// Merge known features (always shown) with custom features from settings,
// while canonicalizing legacy aliases (e.g. devServer → devServerView)
// so only one user-visible row is rendered per feature.
const allFeatureKeys = Array.from(
new Set([
...Object.keys(KNOWN_EXPERIMENTAL_FEATURES),
...Object.keys(experimentalFeatures).map(getCanonicalExperimentalFeatureKey),
])
).sort((a, b) => a.localeCompare(b));
const featureFlags = allFeatureKeys.map((key) => [key, isExperimentalFeatureEnabled(experimentalFeatures, key)] as const);
return (
<>
{renderScopeBanner()}
Experimental Features
Experimental features are early capabilities that are not yet fully stable.
Enable them to test new functionality, but be aware they may change or be removed.
>
);
}
case "backups":
return (
<>
{renderScopeBanner()}
Database Backups
setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))
}
/>
Enable automatic database backups
When enabled, the database is backed up automatically on a schedule
Backup Schedule (Cron)
setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM).
Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min)
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (
Invalid cron expression format
)}
Retention Count
{
const val = e.target.value;
setForm((f) => ({ ...f, autoBackupRetention: val === "" ? undefined : Number(val) }));
}}
disabled={!form.autoBackupEnabled}
/>
Number of backup files to keep (oldest are deleted first). Range: 1-100.
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (
Must be between 1 and 100
)}
Backup Directory
setForm((f) => ({ ...f, autoBackupDir: e.target.value }))
}
disabled={!form.autoBackupEnabled}
/>
Directory for backup files, relative to project root
{form.autoBackupDir && form.autoBackupDir.includes("..") && (
Path cannot contain parent directory traversal (..)
)}
Database Maintenance
Operational log retention
setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))
}
>
Off
30 days
60 days
90 days
180 days
365 days
Prune append-only operational logs (activity log, agent logs, run audit, heartbeats) older than this
many days during periodic maintenance. Keeps the database from growing without bound — large databases
are slower to checkpoint and more prone to corruption. Default: 30 days.
Memory Backups
setForm((f) => ({ ...f, memoryBackupEnabled: e.target.checked }))}
/>
Enable automatic memory backups
When enabled, project and agent memory files are backed up automatically on a schedule.
Memory Backup Schedule (Cron)
setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))}
disabled={!form.memoryBackupEnabled}
/>
Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).
{form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && (
Invalid cron expression format
)}
Memory Retention Count
{
const val = e.target.value;
setForm((f) => ({ ...f, memoryBackupRetention: val === "" ? undefined : Number(val) }));
}}
disabled={!form.memoryBackupEnabled}
/>
Number of memory backups to keep (oldest are deleted first). Range: 1-100.
{form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && (
Must be between 1 and 100
)}
Memory Backup Directory
setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))}
disabled={!form.memoryBackupEnabled}
/>
Directory for memory backups, relative to project root.
{form.memoryBackupDir && form.memoryBackupDir.includes("..") && (
Path cannot contain parent directory traversal (..)
)}
Memory Backup Scope
setForm((f) => ({ ...f, memoryBackupScope: e.target.value as "project" | "agents" | "all" }))}
disabled={!form.memoryBackupEnabled}
>
All (project + agents)
Project only (.fusion/memory)
Agents only (.fusion/agent-memory)
{backupLoading ? (
Loading backup info…
) : backupInfo ? (
Current Backups
{backupInfo.count}
backups
{backupInfo.totalSize > 1024 * 1024
? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB`
: `${(backupInfo.totalSize / 1024).toFixed(1)} KB`}
total size
{backupInfo.backups.length > 0 && (
View {backupInfo.backups.length} backup(s)
{backupInfo.backups.slice(0, 10).map((backup) => (
{backup.filename}
{backup.size > 1024 * 1024
? `${(backup.size / (1024 * 1024)).toFixed(1)} MB`
: `${(backup.size / 1024).toFixed(1)} KB`}
))}
{backupInfo.backups.length > 10 && (
...and {backupInfo.backups.length - 10} more
)}
)}
) : null}
{backupLoading ? "Creating…" : "Backup Now"}
>
);
case "notifications":
return (
<>
{renderScopeBanner()}
Notifications
ntfy
setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))
}
/>
Enable
{form.ntfyEnabled && (
Notify on events
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const checked = form.ntfyEvents?.includes(event) ?? true;
return (
{
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}
{description}
);
})}
Dashboard Hostname
{
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}
/>
Base URL for deep links in notifications. When set, clicking a notification
opens the dashboard directly to the task.
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (
Must be a valid URL starting with http:// or https://
)}
handleTestProviderNotification("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"] ? "Sending…" : "Test notification"}
handleTestProviderNotification("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"] ? "Sending…" : "Test message inbox"}
handleTestProviderNotification("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"] ? "Sending…" : "Test room reply"}
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
{testNotificationResult["ntfy"] && (
General: {testNotificationResult["ntfy"].message}
)}
{testNotificationResult["ntfy-message"] && (
Message inbox: {testNotificationResult["ntfy-message"].message}
)}
{testNotificationResult["ntfy-room"] && (
Room reply: {testNotificationResult["ntfy-room"].message}
)}
)}
)}
Webhook
setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))
}
/>
Webhook notifications
{form.webhookEnabled && (
Webhook URL
{
const val = e.target.value;
setForm((f) => ({ ...f, webhookUrl: val || undefined }));
}}
/>
Format
{
const val = e.target.value as "slack" | "discord" | "generic";
setForm((f) => ({ ...f, webhookFormat: val }));
}}
>
Slack
Discord
Generic
Notify on events
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const checked = currentEvents.includes(event);
return (
{
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}
{description}
);
})}
handleTestProviderNotification("webhook")}
disabled={testNotificationLoading["webhook"] || !form.webhookUrl}
>
{testNotificationLoading["webhook"] ? "Sending…" : "Test notification"}
{testNotificationResult["webhook"] && (
{testNotificationResult["webhook"].message}
)}
)}
>
);
case "node-sync":
return (
<>
{renderScopeBanner()}
Node Sync
setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))
}
/>
Enable automatic settings sync
Automatically synchronize settings between this node and connected remote nodes
{form.settingsSyncEnabled && (
<>
setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))
}
/>
Sync model auth credentials
Include API keys and OAuth tokens in sync operations
Sync interval
setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))
}
>
Every 5 minutes
Every 15 minutes
Every 30 minutes
Every 1 hour
Conflict resolution
setForm((f) => ({ ...f, settingsSyncConflictResolution: e.target.value as "last-write-wins" | "always-ask" | "keep-local" | "keep-remote" }))
}
>
Last write wins
Always ask
Keep local
Keep remote
>
)}
>
);
case "remote": {
const remoteForm = form as Record;
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";
return (
<>
{renderScopeBanner()}
Remote Access
{tunnelState}
{remoteStatus?.provider && · {remoteStatus.provider} }
{remoteStatus?.url && {remoteStatus.url}}
{remoteStatus?.lastError && {remoteStatus.lastError} }
{tunnelState === "stopped" && externalTunnel && (
External {externalTunnel.provider} tunnel detected
{externalTunnel.url &&
{externalTunnel.url}}
{tunnelShareLink?.qrSvg && (
Scan to open:
)}
)}
{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 (
{tailnetUrl && (
Tailnet URL:
{tailnetUrl}
)}
{accessCode && (
Remote access code:
{accessCode}
)}
{tunnelShareLink?.qrSvg && (
Scan to connect:
)}
);
})()}
{!activeProvider &&
Select a provider above to configure remote access. }
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && (
cloudflared is installed
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && (
cloudflared is not installed
void handleInstallCloudflared()}
>
{cloudflaredInstalling ? "Installing…" : "Install cloudflared"}
{cloudflaredInstallError && {cloudflaredInstallError} }
Manual install: {cloudflaredManualInstallCommand()}
{cloudflaredMacFallbackCommand()
? If Homebrew is unavailable: {cloudflaredMacFallbackCommand()}
: null}
)}
{activeProvider && (
)}
{tunnelState === "running" || tunnelState === "starting" ? (
void runRemoteAction("stop", async () => {
await stopRemoteTunnel(projectId);
addToast("Remote tunnel stopped", "success");
})}>
{remoteBusyAction === "stop" ? "Stopping…" : "Stop Tunnel"}
) : (
<>
{externalTunnel ? (
void runRemoteAction("start fresh", async () => {
const formState = form as Record;
const savePayload: Partial = {
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: activeProvider === "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),
};
await updateRemoteSettings(savePayload, projectId);
await killExternalTunnel(projectId);
await startRemoteTunnel(projectId);
addToast("Remote tunnel restarted", "success");
})}>
{remoteBusyAction === "start fresh" ? "Restarting…" : "Start Fresh"}
void runRemoteAction("use existing", async () => {
const formState = form as Record;
const savePayload: Partial = {
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: activeProvider === "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),
};
await updateRemoteSettings(savePayload, projectId);
await startRemoteTunnel(projectId);
addToast("Remote tunnel started", "success");
})}>
{remoteBusyAction === "use existing" ? "Starting…" : "Use Existing"}
) : (
void runRemoteAction("start", async () => {
const formState = form as Record;
const savePayload: Partial = {
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
// Server overrides this with req.socket.localPort
// when starting the tunnel; the value sent here is
// only a fallback if that override doesn't fire.
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: activeProvider === "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),
};
await updateRemoteSettings(savePayload, projectId);
await startRemoteTunnel(projectId);
addToast("Remote tunnel started", "success");
})}>
{remoteBusyAction === "start" ? "Starting…" : "Start Tunnel"}
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (
cloudflared must be installed to start the tunnel
) : null}
>
)}
Advanced Settings
setForm((f) => ({ ...f, remoteShortLivedEnabled: e.target.checked } as SettingsFormState))} />
Enable short-lived tokens
Short-lived TTL (ms)
setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))} />
{remoteShortLivedToken && Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms) }
setForm((f) => ({ ...f, remoteRememberLastRunning: e.target.checked } as SettingsFormState))} />
Remember last running state
Automatically restore tunnel on startup if it was running when last stopped.
Auth Links
void runRemoteAction("regenerate persistent token", async () => {
await regenerateRemotePersistentToken(projectId);
addToast("Persistent token regenerated", "success");
})}>Regenerate persistent token
void runRemoteAction("generate short-lived token", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const generated = await generateShortLivedRemoteToken(ttlMs, projectId);
setRemoteShortLivedToken(generated);
addToast("Short-lived token generated", "success");
})}>Generate short-lived token
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
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
Auth link token type
setRemoteAuthLinkTokenType(e.target.value as "persistent" | "short-lived")}>
Persistent token
Short-lived token
URL and QR generation use the selected token type.
{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""}
{remoteUrlPreview?.url && (
<>
Authenticated URL:{remoteUrlPreview.url}
Token type: {remoteUrlPreview.tokenType}
{remoteUrlPreview.expiresAt ? ` · Expires at ${new Date(remoteUrlPreview.expiresAt).toLocaleString()}` : " · No expiry"}
>
)}
{remoteQrSvg && (
Scan this QR code on your phone
QR SVG markup
{remoteQrSvg}
)}
>
);
}
case "prompts":
return (
<>
{renderScopeBanner()}
Prompts
{
setForm((f) => ({
...f,
agentPrompts,
}));
}}
promptOverrides={form.promptOverrides}
onPromptOverridesChange={(overrides) => {
setForm((f) => ({
...f,
promptOverrides: overrides,
}));
}}
/>
>
);
case "plugins":
return (
<>
{renderScopeBanner()}
Plugins
setActivePluginsSubsection("fusion-plugins")}
>
Fusion Plugins
setActivePluginsSubsection("pi-extensions")}
>
Pi Extensions
{activePluginsSubsection === "fusion-plugins" && (
<>
>
)}
{activePluginsSubsection === "pi-extensions" && (
)}
>
);
case "authentication": {
// CLI-backed providers (currently just claude-cli) render their own
// compact card with Enable/Disable + Test actions — bypassing the
// OAuth/API-key rendering below. Filter them out of the standard
// sort and render alongside.
const cliAuthProviders = authProviders.filter((p) => p.type === "cli");
const nonCliProviders = authProviders.filter((p) => p.type !== "cli");
// Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name.
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);
// CLI-backed providers live in whichever bucket matches their current
// auth state (Authenticated when signed in, Available otherwise).
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli");
const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp");
const claudeCliCard = claudeCliProvider ? (
{
void loadAuthStatus();
}}
/>
) : null;
const cursorCliCard = cursorCliProvider ? (
{
void loadAuthStatus();
}}
/>
) : null;
const llamaCppCard = llamaCppProvider ? (
{
void loadAuthStatus();
}}
/>
) : null;
const showAuthenticatedGroup =
authenticatedProviders.length > 0
|| (claudeCliProvider?.authenticated ?? false)
|| (cursorCliProvider?.authenticated ?? false)
|| (llamaCppProvider?.authenticated ?? false);
const showAvailableGroup =
unauthenticatedProviders.length > 0
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|| (cursorCliProvider && !cursorCliProvider.authenticated)
|| (llamaCppProvider && !llamaCppProvider.authenticated);
return (
<>
Authentication
{authLoading ? (
Loading authentication status…
) : authProviders.length === 0 ? (
No providers available
) : (
{ void loadAuthStatus(); } }}
/>
{ void loadAuthStatus(); } }}
/>
{!showAuthenticatedGroup && (
Sign in to at least one provider to get started with AI models.
)}
{showAuthenticatedGroup && (
Authenticated
{claudeCliProvider?.authenticated && claudeCliCard}
{cursorCliProvider?.authenticated && cursorCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-
*/}
{provider.name}
✓ Active
{provider.authenticated && provider.keyHint && (
Key: {provider.keyHint}
)}
{provider.type === "api_key" ? (
setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (
handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Clear
) : (
handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Save
)}
{authActionInProgress === provider.id && (
Saving…
)}
{apiKeyErrors[provider.id] && (
{apiKeyErrors[provider.id]}
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
{opencodeApiKeyRefreshStatus[provider.id].message}
)}
) : (
{authActionInProgress === provider.id ? (
Logging out…
) : provider.loginInProgress ? (
Waiting for login…
handleCancelLogin(provider.id)}>
Cancel
) : (
handleLogout(provider.id)}
>
Logout
)}
)}
))}
)}
{showAvailableGroup && (
Available
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-
*/}
{provider.name}
✗ Not connected
{provider.type === "api_key" ? (
setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Save
{authActionInProgress === provider.id && (
Saving…
)}
{apiKeyErrors[provider.id] && (
{apiKeyErrors[provider.id]}
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
{opencodeApiKeyRefreshStatus[provider.id].message}
)}
) : (
{authActionInProgress === provider.id ? (
Waiting for login…
) : provider.loginInProgress ? (
Waiting for login…
handleCancelLogin(provider.id)}>
Cancel
) : (
handleLogin(provider.id)}
>
Login
)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
Enter this code on GitHub
{deviceCodes[provider.id].userCode}
{
void (async () => {
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast("Copied code to clipboard", "success");
return;
}
addToast("Failed to copy code — copy it manually from the box above", "error");
})();
}}
>
Copy code
window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}
>
Open GitHub
)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))}
onSubmit={() => void handleSubmitManualCode(provider.id)}
prompt={manualCodeConfigs[provider.id].prompt}
placeholder={manualCodeConfigs[provider.id].placeholder}
helpText={manualCodeConfigs[provider.id].helpText}
disabled={manualCodeSubmitInProgress === provider.id}
submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"}
data-testid={`auth-manual-code-${provider.id}`}
/>
)}
)}
))}
)}
)}
Authentication changes take effect immediately — no need to save.
{onReopenOnboarding && (
Reopen onboarding guide
Re-run the setup wizard to review or update your AI provider and model configuration.
)}
>
);
}
case "hermes-runtime":
return (
<>
Hermes Runtime
>
);
case "openclaw-runtime":
return (
<>
OpenClaw Runtime
>
);
case "paperclip-runtime":
return (
<>
Paperclip Runtime
>
);
}
};
return (
{loading ? (
Loading…
) : (
{showMobileSectionPicker && (
Settings Section
setActiveSection(event.target.value as SectionId)}
>
{visibleSections.filter((section) => !section.isGroupHeader).map((section) => (
{section.label}
))}
)}
{visibleSections.map((section) => {
// Render group headers as non-clickable styled divs
if (section.isGroupHeader) {
return (
{section.label}
);
}
return (
setActiveSection(section.id)}
title={
section.scope === "global"
? "Shared across all projects"
: section.scope === "project"
? "Specific to this project"
: undefined
}
>
{section.scope === "global" && }
{section.scope === "project" && }
{section.icon && !section.scope && (
)}
{section.label}
);
})}
{renderSectionFields()}
)}
Help
{appVersion && (
{
void handleCheckForUpdates();
}}
disabled={updateCheckLoading}
aria-label="Check for updates"
title="Check for updates"
>
Version {appVersion}
)}
{updateCheckResult && (
{renderUpdateCheckResultContent()}
)}
Export
fileInputRef.current?.click()}
disabled={importLoading}
title="Import settings from JSON file"
>
{importLoading ? "Loading…" : "Import"}
Cancel
Save
{overlapPathPickerIndex !== null && (
event.stopPropagation()}>
Select ignored overlap path
×
Choose a file to ignore directly, or navigate into a folder and select the current directory.
Current directory: {overlapPathPickerCurrentPath === "." ? "(project root)" : overlapPathPickerCurrentPath}
Cancel
Select current directory
)}
{worktreesDirPickerOpen && (
event.stopPropagation()}>
Select worktrees directory
×
Navigate to the folder where Fusion should create task worktrees, then select the current directory.
Current directory: {worktreesDirPickerCurrentPath === "." ? "(project root)" : worktreesDirPickerCurrentPath}
Cancel
Select current directory
)}
{/* Import Confirmation Dialog */}
{importDialogOpen && importPreview && (
e.target === e.currentTarget && setImportDialogOpen(false)} role="dialog" aria-modal="true">
Import Settings
setImportDialogOpen(false)} aria-label="Close">
×
Review the settings to be imported:
{importPreview.global && Object.keys(importPreview.global).length > 0 && (
Global Settings:
{Object.entries(importPreview.global)
.filter(([, v]) => v !== undefined)
.map(([key]) => (
{key}
))}
)}
{importPreview.project && Object.keys(importPreview.project).length > 0 && (
Project Settings:
{Object.entries(importPreview.project)
.filter(([, v]) => v !== undefined)
.map(([key]) => (
{key}
))}
)}
Import Scope:
setImportScope(e.target.value as 'global' | 'project' | 'both')}
>
Both global and project settings
Global settings only
Project settings only
setImportMerge(e.target.checked)}
/>
Merge with existing settings (recommended)
If unchecked, existing settings will be replaced with imported values.
setImportDialogOpen(false)}>
Cancel
{importLoading ? "Importing…" : "Confirm Import"}
)}
);
}