diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 793ffaee4c..6556421ee5 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1,12 +1,11 @@ -import { useState, useEffect, useCallback, useRef, lazy, Suspense, type CSSProperties, type MouseEvent } from "react"; -import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react"; +import { useState, useEffect, useCallback, useRef, type CSSProperties, type MouseEvent } from "react"; +import { Globe, Folder, RefreshCw, Star, HelpCircle } from "lucide-react"; import { - AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, getErrorMessage, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, } from "@fusion/core"; -import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; +import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api"; import { splitSettingsSave } from "./settings/save-split"; @@ -24,28 +23,32 @@ import { OpenClawRuntimeSection, PaperclipRuntimeSection, } from "./settings/sections/RuntimesSections"; -import { MovedSettingsStub } from "./settings/sections/MovedSettingsStub"; import { SecretsSection } from "./settings/sections/SecretsSection"; import { PromptsSection } from "./settings/sections/PromptsSection"; -import { ProjectDefaultWorkflowField } from "./WorkflowSelector"; +import { GeneralSection } from "./settings/sections/GeneralSection"; +import { ProjectModelsSection } from "./settings/sections/ProjectModelsSection"; +import { SchedulingSection } from "./settings/sections/SchedulingSection"; +import { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection"; +import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection"; +import { WorktreesSection } from "./settings/sections/WorktreesSection"; +import { CommandsSection } from "./settings/sections/CommandsSection"; +import { MergeSection } from "./settings/sections/MergeSection"; +import { AgentPermissionsSection } from "./settings/sections/AgentPermissionsSection"; +import { MemorySection } from "./settings/sections/MemorySection"; +import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection"; +import { BackupsSection } from "./settings/sections/BackupsSection"; +import { PluginsSection } from "./settings/sections/PluginsSection"; import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; import { useTranslation } from "react-i18next"; import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref"; import "./SettingsModal.css"; -import { CustomModelDropdown } from "./CustomModelDropdown"; -import { FileEditor } from "./FileEditor"; import { FileBrowser } from "./FileBrowser"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; -const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager }))); -const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager }))); -import { PluginSlot } from "./PluginSlot"; import { ProviderIcon } from "./ProviderIcon"; -import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor"; -import { AgentProvisioningPolicyEditor } from "./AgentProvisioningPolicyEditor"; -import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets"; +import { generateUniquePresetId } from "../utils/modelPresets"; import { copyTextToClipboard } from "../utils/copyToClipboard"; import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth"; import { useConfirm } from "../hooks/useConfirm"; @@ -54,8 +57,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; import { useViewportMode } from "../hooks/useViewportMode"; import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus"; -import { NodeHealthDot } from "./NodeHealthDot"; -import { TrackingRepoSelect, type TrackingRepoOption } from "./TrackingRepoSelect"; +import { type TrackingRepoOption } from "./TrackingRepoSelect"; import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility"; // --------------------------------------------------------------------------- @@ -81,20 +83,6 @@ function DiscordIcon({ size = 13 }: { size?: number }) { ); } -function toCompleteAgentPermissionRules(rules?: Partial): AgentPermissionPolicyRules { - return AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.reduce((acc, category) => { - acc[category] = rules?.[category] ?? "allow"; - return acc; - }, {} as AgentPermissionPolicyRules); -} - -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"; -} - function toTrackingRepoOptions(remotes: GitRemote[]): TrackingRepoOption[] { const byValue = new Map(); for (const remote of remotes) { @@ -222,23 +210,6 @@ type SettingsSection = { const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)"; const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md"; -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)}`; -} - -function formatMemoryFileOptionLabel(file: MemoryFileInfo): string { - const fullLabel = `${file.label} — ${file.path}`; - return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS); -} const SETTINGS_SECTIONS: SettingsSection[] = [ // Account group (scope-less items — independent of settings storage) @@ -281,8 +252,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project" }, ]; -const MS_PER_DAY = 24 * 60 * 60 * 1000; -const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2; /** Well-known experimental feature flags with display labels. * These always appear in the Experimental Features settings tab, * regardless of whether they exist in the project's settings blob. @@ -2220,286 +2189,18 @@ export function SettingsModal({ switch (activeSection) { case "general": return ( - <> - {renderScopeBanner()} -

General

-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, taskPrefix: val || undefined })); - if (val && !/^[A-Z]{1,10}$/.test(val)) { - setPrefixError("Prefix must be 1–10 uppercase letters"); - } else { - setPrefixError(null); - } - }} - /> - {prefixError && {prefixError}} - {!prefixError && Prefix for new task IDs (e.g. KB, PROJ)} -
-
- - New tasks inherit this custom workflow's steps (overridable per task) -
-
- - - 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. - -
-
- - - - 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. - -
-
- - Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation. -
-

Chat history

-
- - - Delete chat sessions and rooms that have been idle for this many days. Default: Off. -
-
- - - Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting. -
-
- - - - Lowering this window means Reliability metrics/charts and the Activity feed will not show history older - than the selected range. Per-task task detail history is unaffected. Default: 30 days. - -
-

Chat Rooms

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

Capacity Risk Banner

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

GitHub Tracking

-
- - - - 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." - : ""} - -
-
- - - setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) - } - /> - Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank. -
-
- - - When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. - -
- + ); case "global-general": return ( @@ -2531,394 +2232,34 @@ export function SettingsModal({ case "secrets": return ; - 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)); - - // Only the project DEFAULT model lane survives in this modal. The - // per-phase execution/planning/validator lanes, their fallbacks, and the - // title-summarizer lane were hard-moved (U4) onto the workflow settings - // mechanism — they are no longer project settings keys and must never be - // renderable or savable here (redirect stub below). - const projectModelLanes = MODEL_LANES.filter((lane) => lane.laneId === "default"); - 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; - + case "project-models": return ( - <> - {renderScopeBanner()} - - {/* --- Token Cap --- */} -

Token Cap

-
- -
- { - const val = e.target.value; - setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState)); - }} - /> - {form.tokenCap != null && ( - - )} -
- 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. -
- - {/* --- 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 ( -
-
- - - {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 && ( - - )} -
- - {getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}. - -
- ); - })} - - )} - - {/* --- Per-phase model lanes (MOVED to workflow settings) --- */} -

Per-phase model lanes

- - - {/* --- Model Presets --- */} -

Model 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} -
-
- - -
-
- ); - })} -
- )} - {!presetDraft ? ( -
- -
- ) : null} -
- - {presetDraft ? ( -
- -
-
- - { - const name = e.target.value; - setPresetDraft((current) => current ? { ...current, name } : current); - }} - /> -
- {availableModels.length === 0 ? ( - No models available. Configure authentication first. - ) : ( - <> -
- - { - 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} - /> -
-
- - { - 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} - /> -
- - )} -
-
- - -
-
- ) : null} - -
- -
- - {form.autoSelectModelPreset ? ( -
- {(["S", "M", "L"] as const).map((sizeKey) => ( -
- - -
- ))} -
- ) : 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. -

-
- - - 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. - -
- -
- - - 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) && ( -

- {t( - "settings.movedStub.summarizerModelInline", - "The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.", - )} -

- )} - + ); - } - case "appearance": return ( - {renderScopeBanner()} -

Scheduling

-
- - { - const val = e.target.value; - globalConcurrencyDirtyRef.current = true; - setGlobalMaxConcurrent(val === "" ? undefined : Number(val)); - }} - /> - Maximum concurrent agents across all projects -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> - Maximum concurrent planning agents -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> -
-
- - - Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure. -
-
- - { - 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. -
-
- - { - 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. -
-
- - 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. -
-
- - When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning -
-
- - { - 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. -
-
- - Completed tasks older than the threshold are moved out of the active task database. -
-
- - { - 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). -
-
- - - Compact mode keeps archive size low while preserving recent agent activity for context. -
-
- - { - 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. -
-
- - When enabled, tasks that modify the same files are queued serially to avoid merge conflicts -
- -
- - - 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) => ( -
-
- handleOverlapIgnorePathChange(index, e.target.value)} - /> - -
- -
- ))} -
- -
- -
- -
Step Execution
- - + { + globalConcurrencyDirtyRef.current = true; + setGlobalMaxConcurrent(value); + }} + onOverlapIgnorePathChange={handleOverlapIgnorePathChange} + onOpenOverlapPathPicker={openOverlapPathPicker} + onRemoveOverlapIgnorePath={handleRemoveOverlapIgnorePath} + onAddOverlapIgnorePath={handleAddOverlapIgnorePath} + onOpenWorkflowSettings={onOpenWorkflowSettings} + /> ); - case "scheduled-evals": { - const evalSettings = form.evalSettings ?? {}; - const isScheduledEvalEnabled = evalSettings.enabled ?? false; - + case "scheduled-evals": return ( - <> - {renderScopeBanner()} -

Scheduled Evals

-
- -
-
- - - setForm((current) => ({ - ...current, - evalSettings: { - ...(current.evalSettings ?? {}), - intervalMs: event.target.value === "" ? undefined : Number(event.target.value), - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - evalSettings: { - ...(current.evalSettings ?? {}), - evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value, - }, - })) - } - placeholder="openai" - /> -
-
- - - 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. - -
-
- - -
-
- - - 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.

-
- - - {(() => { - 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. -
-
- - -
- + ); - case "worktrees": return ( - <> - {renderScopeBanner()} -

Worktrees

-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as SettingsFormState)); - }} - /> - Limits total git worktrees including in-review tasks -
-
- - - setForm((f) => ({ ...f, worktreeInitCommand: e.target.value })) - } - /> - Shell command to run in each new worktree after creation -
-
- - 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 -
-
- - - 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. - -
-
- - - - {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, worktreesDir: e.target.value })) - } - /> - -
- - {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 ~ and {"{repo}"}. Defaults to <projectRoot>/.worktrees when unset. Only affects newly-created worktrees. - } - -
-
- - 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 && ( -
- - - - Which remote to fetch for the pre-merge rebase. "Use git default" falls back to the remote configured for the default branch (typically origin). - -
- )} -
- - - 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

-
- - - 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") && ( - <> - - Enable worktrunk and request approval to install the pinned release. - - )} - {worktrunkInstall.status === "pending-approval" && ( - <> - {t("settings.worktrees.awaitingApproval", "Awaiting approval — open Approvals to continue.")} - - - )} - {(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && ( - <> - {worktrunkInstall.error ?? "Worktrunk install failed."} - - - )} -
-
- - - 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. -
-
- - - - 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

-
- - - setForm((f) => ({ ...f, testCommand: e.target.value || undefined })) - } - /> - Command used to run tests — injected into generated task specs -
-
- - - setForm((f) => ({ ...f, buildCommand: e.target.value || undefined })) - } - /> - Command used to build the project — injected into generated task specs -
- + ); case "merge": return ( - <> - {renderScopeBanner()} -

Merge

-
- -
- More details - When enabled, tasks that pass review are automatically merged into the main branch -
-
-
- - -
- 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" && ( - <> -
- - - 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. -
-
- -
- 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. - -
-
- - )} -
- -
- More details - Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost. -
-
- -
- - -
- 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. - -
-
-
- - {(() => { - const currentValue = form.integrationBranch ?? ""; - const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue); - const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown); - if (isCustomMode) { - return ( -
- { - const trimmed = e.target.value.trim(); - setForm((f) => ({ - ...f, - integrationBranch: trimmed.length === 0 ? undefined : trimmed, - })); - }} - data-testid="integration-branch-custom-input" - /> - -
- ); - } - const CUSTOM = "__fusion-custom__"; - const AUTO = ""; - return ( - - ); - })()} -
- 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" && ( - <> -
- - -
- 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. - -
-
-
- - - - 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). -
- )} -
-
- - -
- 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. - -
-
- - )} -

GitHub Authentication

-
- - -
- {(form.githubAuthMode ?? "gh-cli") === "token" && ( -
- - - setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined })) - } - /> -
- )} -
- -
- More details - When disabled, merge commit messages omit the task ID from the scope (e.g. feat: ... instead of feat(KB-001): ...) -
-
-
- -
- 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 && ( - <> -
- - - setForm((f) => ({ - ...f, - commitAuthorName: e.target.value || undefined, - })) - } - /> - Name used in the Co-authored-by trailer -
-
- - - setForm((f) => ({ - ...f, - commitAuthorEmail: e.target.value || undefined, - })) - } - /> - Email used in the Co-authored-by trailer -
- - )} - -
- -
- 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" && ( - <> -
- -
- 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. -
-
-
- - -
- 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. - -
-
-
- - - - When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. - -
-
- - - - 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. - -
- - )} -
- -
- 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 && ( -
- - - 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", - }; - + case "memory": 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. - -
- -
- - 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"} - - -
- )} - -
- - Automatically compact memory when it exceeds the threshold on a schedule -
- - {(form.memoryAutoSummarizeEnabled || false) && ( - <> -
- - - setForm((f) => ({ - ...f, - memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000, - })) - } - min={1000} - /> - Memory will be compacted when it exceeds this character count -
-
- - - setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value })) - } - placeholder="0 3 * * *" - /> - Cron expression for auto-summarize schedule (default: daily at 3 AM) -
- - )} - -
- -
- - Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md. -
- - {isMemoryEnabled && form.memoryDreamsEnabled === true && ( - <> -
- - - setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value })) - } - /> - Cron expression for dream processing. -
-
- - Manually trigger dream processing now. -
- - )} - -
-
- - setMemoryTestQuery(e.target.value)} - placeholder="Search memory with qmd" - /> - Runs the same qmd-backed memory_search path agents use. -
-
- -
- {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…
- ) : ( -
-
- - - - {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?.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 && ( -
- - - {memoryDirty - ? "Save or discard edits before compacting this file." - : `Compacts ${selectedMemoryPath} and writes the result back to the same file.`} - -
- )} - - {memoryDirty && isEditingAllowed && ( -
- -
- )} - {memoryDirty && !isEditingAllowed && ( -
- Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"} -
- )} - + ); - } case "research-global": return ( ); - case "research-project": { - const limits = form.researchSettings?.limits; - const sources = form.researchSettings?.enabledSources; + case "research-project": return ( - <> - {renderScopeBanner()} -

Project Research Settings

-
- -
-
- - - - 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]) => ( - - ))} -
-
-
-
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchSettings: { - ...(current.researchSettings ?? {}), - limits: { - ...(current.researchSettings?.limits ?? {}), - requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value), - }, - }, - })) - } - /> -
- {researchLimitError && {researchLimitError}} -
-
- + ); - } case "experimental": return ( - {renderScopeBanner()} -

Database Backups

-
- - When enabled, the database is backed up automatically on a schedule -
-
- - - setForm((f) => ({ ...f, autoBackupSchedule: e.target.value })) - } - disabled={!form.autoBackupEnabled} - /> - - Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). - Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) - - {form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && ( - Invalid cron expression format - )} -
-
- - { - 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 - )} -
-
- - - 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 (..) - )} -
- -

Memory Backups

-
- - When enabled, project and agent memory files are backed up automatically on a schedule. -
-
- - setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))} - disabled={!form.memoryBackupEnabled} - /> - Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM). - {form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && ( - Invalid cron expression format - )} -
-
- - { - 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 - )} -
-
- - 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 (..) - )} -
-
- - -
- {backupLoading ? ( -
Loading backup info…
- ) : backupInfo ? ( -
- -
-
- {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} -
- -
- + ); case "notifications": return ( @@ -5131,65 +2481,13 @@ export function SettingsModal({ return ; case "plugins": return ( - <> - {renderScopeBanner()} -

Plugins

-
- - -
- - - + ); case "authentication": return ( diff --git a/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx b/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx new file mode 100644 index 0000000000..90bb44437c --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/AgentPermissionsSection.tsx @@ -0,0 +1,60 @@ +/** + * 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"; + +function toCompleteAgentPermissionRules(rules?: Partial): 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; +} + +export function AgentPermissionsSection({ scopeBanner, form, setForm }: AgentPermissionsSectionProps) { + return ( + <> + {scopeBanner} +

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 }))} + /> + + ); +} + +export default AgentPermissionsSection; diff --git a/packages/dashboard/app/components/settings/sections/BackupsSection.tsx b/packages/dashboard/app/components/settings/sections/BackupsSection.tsx new file mode 100644 index 0000000000..66d6fe7129 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/BackupsSection.tsx @@ -0,0 +1,229 @@ +/** + * 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; +} + +export function BackupsSection({ scopeBanner, form, setForm, backupInfo, backupLoading, onBackupNow }: BackupsSectionProps) { + const { t } = useTranslation("app"); + return ( + <> + {scopeBanner} +

Database Backups

+
+ + When enabled, the database is backed up automatically on a schedule +
+
+ + + setForm((f) => ({ ...f, autoBackupSchedule: e.target.value })) + } + disabled={!form.autoBackupEnabled} + /> + + Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). + Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) + + {form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && ( + Invalid cron expression format + )} +
+
+ + { + 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 + )} +
+
+ + + 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 (..) + )} +
+ +

Memory Backups

+
+ + When enabled, project and agent memory files are backed up automatically on a schedule. +
+
+ + setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))} + disabled={!form.memoryBackupEnabled} + /> + Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM). + {form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && ( + Invalid cron expression format + )} +
+
+ + { + 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 + )} +
+
+ + 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 (..) + )} +
+
+ + +
+ {backupLoading ? ( +
Loading backup info…
+ ) : backupInfo ? ( +
+ +
+
+ {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} +
+ +
+ + ); +} + +export default BackupsSection; diff --git a/packages/dashboard/app/components/settings/sections/CommandsSection.tsx b/packages/dashboard/app/components/settings/sections/CommandsSection.tsx new file mode 100644 index 0000000000..74765a36cf --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/CommandsSection.tsx @@ -0,0 +1,49 @@ +/** + * 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"; + +export interface CommandsSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; +} + +export function CommandsSection({ scopeBanner, form, setForm }: CommandsSectionProps) { + return ( + <> + {scopeBanner} +

Commands

+
+ + + setForm((f) => ({ ...f, testCommand: e.target.value || undefined })) + } + /> + Command used to run tests — injected into generated task specs +
+
+ + + setForm((f) => ({ ...f, buildCommand: e.target.value || undefined })) + } + /> + Command used to build the project — injected into generated task specs +
+ + ); +} + +export default CommandsSection; diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx new file mode 100644 index 0000000000..a0ea50bd7a --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -0,0 +1,326 @@ +/** + * 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 type { ReactNode } from "react"; +import { ProjectDefaultWorkflowField } from "../../WorkflowSelector"; +import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect"; +import type { ToastType } from "../../../hooks/useToast"; +import type { SectionBaseProps } from "./context"; + +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; +} + +export function GeneralSection({ + scopeBanner, + form, + setForm, + projectId, + addToast, + prefixError, + setPrefixError, + projectTrackingRepoOptions, + projectTrackingRepoLoading, + projectTrackingRepoError, +}: GeneralSectionProps) { + return ( + <> + {scopeBanner} +

General

+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, taskPrefix: val || undefined })); + if (val && !/^[A-Z]{1,10}$/.test(val)) { + setPrefixError("Prefix must be 1–10 uppercase letters"); + } else { + setPrefixError(null); + } + }} + /> + {prefixError && {prefixError}} + {!prefixError && Prefix for new task IDs (e.g. KB, PROJ)} +
+
+ + New tasks inherit this custom workflow's steps (overridable per task) +
+
+ + + 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. + +
+
+ + + + 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. + +
+
+ + Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation. +
+

Chat history

+
+ + + Delete chat sessions and rooms that have been idle for this many days. Default: Off. +
+
+ + + Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting. +
+
+ + + + Lowering this window means Reliability metrics/charts and the Activity feed will not show history older + than the selected range. Per-task task detail history is unaffected. Default: 30 days. + +
+

Chat Rooms

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

Capacity Risk Banner

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

GitHub Tracking

+
+ + + + 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." + : ""} + +
+
+ + + setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) + } + /> + Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank. +
+
+ + + When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. + +
+ + ); +} + +export default GeneralSection; diff --git a/packages/dashboard/app/components/settings/sections/MemorySection.tsx b/packages/dashboard/app/components/settings/sections/MemorySection.tsx new file mode 100644 index 0000000000..061059724f --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/MemorySection.tsx @@ -0,0 +1,429 @@ +/** + * 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 { 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)}`; +} + +function formatMemoryFileOptionLabel(file: MemoryFileInfo): string { + 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; +} + +export interface MemorySectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + memory: MemorySectionMemoryProps; +} + +export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySectionProps) { + const { t } = useTranslation("app"); + const { + memoryCapabilities: capabilities, + memoryBackendStatus: backendStatus, + memoryBackendLoading: backendLoading, + memoryBackendError: backendError, + memoryFiles, + selectedMemoryPath, + setSelectedMemoryPath, + memoryContent, + setMemoryContent, + memoryLoading, + memoryDirty, + setMemoryDirty, + memoryTestQuery, + setMemoryTestQuery, + memoryTestLoading, + memoryTestResult, + qmdInstallLoading, + dreamRunning, + memoryCompactLoading, + onInstallQmd, + onTestMemoryRetrieval, + onDreamNow, + onCompactMemory, + onSaveMemory, + } = memory; + + // Determine if editing is allowed + const isMemoryEnabled = form.memoryEnabled !== false; + const backendStatusResolved = !backendLoading && backendStatus !== null; + const isBackendWritable = backendStatusResolved ? (capabilities?.writable ?? true) : true; + const isEditingAllowed = isMemoryEnabled && isBackendWritable; + + const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath); + const memoryLayerNames: Record = { + "long-term": "Long-term", + daily: "Daily", + dreams: "Dreams", + }; + + return ( + <> + {scopeBanner} +

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. + +
+ +
+ + 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"} + + +
+ )} + +
+ + Automatically compact memory when it exceeds the threshold on a schedule +
+ + {(form.memoryAutoSummarizeEnabled || false) && ( + <> +
+ + + setForm((f) => ({ + ...f, + memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000, + })) + } + min={1000} + /> + Memory will be compacted when it exceeds this character count +
+
+ + + setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value })) + } + placeholder="0 3 * * *" + /> + Cron expression for auto-summarize schedule (default: daily at 3 AM) +
+ + )} + +
+ +
+ + Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md. +
+ + {isMemoryEnabled && form.memoryDreamsEnabled === true && ( + <> +
+ + + setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value })) + } + /> + Cron expression for dream processing. +
+
+ + Manually trigger dream processing now. +
+ + )} + +
+
+ + setMemoryTestQuery(e.target.value)} + placeholder="Search memory with qmd" + /> + Runs the same qmd-backed memory_search path agents use. +
+
+ +
+ {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…
+ ) : ( +
+
+ + + + {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?.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 && ( +
+ + + {memoryDirty + ? "Save or discard edits before compacting this file." + : `Compacts ${selectedMemoryPath} and writes the result back to the same file.`} + +
+ )} + + {memoryDirty && isEditingAllowed && ( +
+ +
+ )} + {memoryDirty && !isEditingAllowed && ( +
+ Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"} +
+ )} + + ); +} + +export default MemorySection; diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx new file mode 100644 index 0000000000..86021f9081 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -0,0 +1,605 @@ +/** + * 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 { useTranslation } from "react-i18next"; +import type { Settings } from "@fusion/core"; +import { MovedSettingsStub } from "./MovedSettingsStub"; +import type { SectionBaseProps } from "./context"; + +export interface MergeSectionProps extends SectionBaseProps { + 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"); + return ( + <> + {scopeBanner} +

Merge

+
+ +
+ More details + When enabled, tasks that pass review are automatically merged into the main branch +
+
+
+ + +
+ 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" && ( + <> +
+ + + 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. +
+
+ +
+ 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. + +
+
+ + )} +
+ +
+ More details + Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost. +
+
+ +
+ + +
+ 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. + +
+
+
+ + {(() => { + const currentValue = form.integrationBranch ?? ""; + const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue); + const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown); + if (isCustomMode) { + return ( +
+ { + const trimmed = e.target.value.trim(); + setForm((f) => ({ + ...f, + integrationBranch: trimmed.length === 0 ? undefined : trimmed, + })); + }} + data-testid="integration-branch-custom-input" + /> + +
+ ); + } + const CUSTOM = "__fusion-custom__"; + const AUTO = ""; + return ( + + ); + })()} +
+ 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" && ( + <> +
+ + +
+ 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. + +
+
+
+ + + + 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). +
+ )} +
+
+ + +
+ 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. + +
+
+ + )} +

GitHub Authentication

+
+ + +
+ {(form.githubAuthMode ?? "gh-cli") === "token" && ( +
+ + + setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined })) + } + /> +
+ )} +
+ +
+ More details + When disabled, merge commit messages omit the task ID from the scope (e.g. feat: ... instead of feat(KB-001): ...) +
+
+
+ +
+ 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 && ( + <> +
+ + + setForm((f) => ({ + ...f, + commitAuthorName: e.target.value || undefined, + })) + } + /> + Name used in the Co-authored-by trailer +
+
+ + + setForm((f) => ({ + ...f, + commitAuthorEmail: e.target.value || undefined, + })) + } + /> + Email used in the Co-authored-by trailer +
+ + )} + +
+ +
+ 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" && ( + <> +
+ +
+ 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. +
+
+
+ + +
+ 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. + +
+
+
+ + + + When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. + +
+
+ + + + 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. + +
+ + )} +
+ +
+ 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 && ( +
+ + + 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". +
+
+ )} + + ); +} + +export default MergeSection; diff --git a/packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx b/packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx new file mode 100644 index 0000000000..136aa876dd --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/NodeRoutingSection.tsx @@ -0,0 +1,88 @@ +/** + * 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"; +} + +export interface NodeRoutingSectionProps { + scopeBanner: ReactNode; + form: SettingsFormState; + setForm: SetSettingsForm; + nodes: NodeInfo[]; +} + +export function NodeRoutingSection({ scopeBanner, form, setForm, nodes }: NodeRoutingSectionProps) { + return ( + <> + {scopeBanner} +

Node Routing

+

Configure how tasks are routed to execution nodes.

+

These settings apply at the project level.

+
+ + + {(() => { + 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. +
+
+ + +
+ + ); +} + +export default NodeRoutingSection; diff --git a/packages/dashboard/app/components/settings/sections/PluginsSection.tsx b/packages/dashboard/app/components/settings/sections/PluginsSection.tsx new file mode 100644 index 0000000000..de1887ad75 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/PluginsSection.tsx @@ -0,0 +1,98 @@ +/** + * 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"; + +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; +} + +export function PluginsSection({ + scopeBanner, + projectId, + addToast, + activePluginsSubsection, + setActivePluginsSubsection, +}: PluginsSectionProps) { + return ( + <> + {scopeBanner} +

Plugins

+
+ + +
+ + + + ); +} + +export default PluginsSection; diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx new file mode 100644 index 0000000000..6fa889323a --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -0,0 +1,461 @@ +/** + * Project Models section (U9 / KTD-10). + * + * Project-scoped model configuration that survives the workflow hard-move: token + * cap, the project DEFAULT model lane, model presets (with the inline editor and + * size-based auto-selection), and the title/commit summarization toggles. The + * per-phase execution/planning/validator lanes and the title-summarizer lane + * moved to the workflow (U4) and render as a redirect stub. The model-lane + * helpers, preset draft state/handlers, available-model list, favorites, and the + * confirm dialog all live in the shell (they share state with the save flow and + * the global model lanes) and are relayed through a `models` prop bag — mirroring + * the Authentication/Remote section conventions. Keys, lane labels, and + * conditional rendering are preserved verbatim from the original inline JSX. + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { ModelPreset, Settings } from "@fusion/core"; +import type { ModelInfo } from "../../../api"; +import { CustomModelDropdown } from "../../CustomModelDropdown"; +import { applyPresetToSelection } from "../../../utils/modelPresets"; +import { MovedSettingsStub } from "./MovedSettingsStub"; +import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context"; + +type LaneStatus = "inherited" | "overridden"; + +export interface ProjectModelsSectionModelProps { + modelLanes: ModelLane[]; + getLaneStatus: (lane: ModelLane) => LaneStatus; + getLaneValue: (lane: ModelLane) => string; + updateLaneValue: (lane: ModelLane, value: string) => void; + resetLaneValue: (lane: ModelLane) => void; + availableModels: ModelInfo[]; + modelsLoading: boolean; + favoriteProviders: string[]; + favoriteModels: string[]; + onToggleFavorite: (provider: string) => void; + onToggleModelFavorite: (modelId: string) => void; + editingPresetId: string | null; + setEditingPresetId: (id: string | null) => void; + presetDraft: ModelPreset | null; + setPresetDraft: (updater: ModelPreset | null | ((prev: ModelPreset | null) => ModelPreset | null)) => void; + onSavePresetDraft: () => void; + confirmDelete: (options: { title: string; message: string; danger?: boolean }) => Promise; +} + +export interface ProjectModelsSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + models: ProjectModelsSectionModelProps; + onOpenWorkflowSettings?: () => void; +} + +export function ProjectModelsSection({ scopeBanner, form, setForm, models, onOpenWorkflowSettings }: ProjectModelsSectionProps) { + const { t } = useTranslation("app"); + const { + modelLanes, + getLaneStatus, + getLaneValue, + updateLaneValue, + resetLaneValue, + availableModels, + modelsLoading, + favoriteProviders, + favoriteModels, + onToggleFavorite, + onToggleModelFavorite, + editingPresetId, + setEditingPresetId, + presetDraft, + setPresetDraft, + onSavePresetDraft, + confirmDelete, + } = 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)); + + // Only the project DEFAULT model lane survives in this modal. The + // per-phase execution/planning/validator lanes, their fallbacks, and the + // title-summarizer lane were hard-moved (U4) onto the workflow settings + // mechanism — they are no longer project settings keys and must never be + // renderable or savable here (redirect stub below). + const projectModelLanes = modelLanes.filter((lane) => lane.laneId === "default"); + 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 ( + <> + {scopeBanner} + + {/* --- Token Cap --- */} +

Token Cap

+
+ +
+ { + const val = e.target.value; + setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState)); + }} + /> + {form.tokenCap != null && ( + + )} +
+ 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. +
+ + {/* --- 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 ( +
+
+ + + {isOverridden ? "Override (Project)" : "Inherited (Global)"} + +
+
+
+ updateLaneValue(lane, val)} + placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} + favoriteProviders={favoriteProviders} + onToggleFavorite={onToggleFavorite} + favoriteModels={favoriteModels} + onToggleModelFavorite={onToggleModelFavorite} + /> +
+ {isOverridden && ( + + )} +
+ + {getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}. + +
+ ); + })} + + )} + + {/* --- Per-phase model lanes (MOVED to workflow settings) --- */} +

Per-phase model lanes

+ + + {/* --- Model Presets --- */} +

Model 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} +
+
+ + +
+
+ ); + })} +
+ )} + {!presetDraft ? ( +
+ +
+ ) : null} +
+ + {presetDraft ? ( +
+ +
+
+ + { + const name = e.target.value; + setPresetDraft((current) => current ? { ...current, name } : current); + }} + /> +
+ {availableModels.length === 0 ? ( + No models available. Configure authentication first. + ) : ( + <> +
+ + { + 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={onToggleFavorite} + favoriteModels={favoriteModels} + onToggleModelFavorite={onToggleModelFavorite} + /> +
+
+ + { + 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={onToggleFavorite} + favoriteModels={favoriteModels} + onToggleModelFavorite={onToggleModelFavorite} + /> +
+ + )} +
+
+ + +
+
+ ) : null} + +
+ +
+ + {form.autoSelectModelPreset ? ( +
+ {(["S", "M", "L"] as const).map((sizeKey) => ( +
+ + +
+ ))} +
+ ) : 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. +

+
+ + + 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. + +
+ +
+ + + 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) && ( +

+ {t( + "settings.movedStub.summarizerModelInline", + "The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.", + )} +

+ )} + + ); +} + +export default ProjectModelsSection; diff --git a/packages/dashboard/app/components/settings/sections/ResearchProjectSection.tsx b/packages/dashboard/app/components/settings/sections/ResearchProjectSection.tsx new file mode 100644 index 0000000000..679a746c19 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ResearchProjectSection.tsx @@ -0,0 +1,183 @@ +/** + * 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"; + +export interface ResearchProjectSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + researchLimitError: string | null; +} + +export function ResearchProjectSection({ scopeBanner, form, setForm, researchLimitError }: ResearchProjectSectionProps) { + const limits = form.researchSettings?.limits; + const sources = form.researchSettings?.enabledSources; + return ( + <> + {scopeBanner} +

Project Research Settings

+
+ +
+
+ + + + 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]) => ( + + ))} +
+
+
+
+
+ + + setForm((current) => ({ + ...current, + researchSettings: { + ...(current.researchSettings ?? {}), + limits: { + ...(current.researchSettings?.limits ?? {}), + maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value), + }, + }, + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + researchSettings: { + ...(current.researchSettings ?? {}), + limits: { + ...(current.researchSettings?.limits ?? {}), + maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), + }, + }, + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + researchSettings: { + ...(current.researchSettings ?? {}), + limits: { + ...(current.researchSettings?.limits ?? {}), + maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value), + }, + }, + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + researchSettings: { + ...(current.researchSettings ?? {}), + limits: { + ...(current.researchSettings?.limits ?? {}), + requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value), + }, + }, + })) + } + /> +
+ {researchLimitError && {researchLimitError}} +
+
+ + ); +} + +export default ResearchProjectSection; diff --git a/packages/dashboard/app/components/settings/sections/ScheduledEvalsSection.tsx b/packages/dashboard/app/components/settings/sections/ScheduledEvalsSection.tsx new file mode 100644 index 0000000000..52885ddeb2 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ScheduledEvalsSection.tsx @@ -0,0 +1,152 @@ +/** + * 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"; + +export interface ScheduledEvalsSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; +} + +export function ScheduledEvalsSection({ scopeBanner, form, setForm }: ScheduledEvalsSectionProps) { + const evalSettings = form.evalSettings ?? {}; + const isScheduledEvalEnabled = evalSettings.enabled ?? false; + + return ( + <> + {scopeBanner} +

Scheduled Evals

+
+ +
+
+ + + setForm((current) => ({ + ...current, + evalSettings: { + ...(current.evalSettings ?? {}), + intervalMs: event.target.value === "" ? undefined : Number(event.target.value), + }, + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + evalSettings: { + ...(current.evalSettings ?? {}), + evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value, + }, + })) + } + placeholder="openai" + /> +
+
+ + + 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. + +
+
+ + +
+
+ + + setForm((current) => ({ + ...current, + evalSettings: { + ...(current.evalSettings ?? {}), + retentionDays: event.target.value === "" ? undefined : Number(event.target.value), + }, + })) + } + /> +
+ + ); +} + +export default ScheduledEvalsSection; diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx new file mode 100644 index 0000000000..43ad43a4e4 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx @@ -0,0 +1,355 @@ +/** + * 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; +} + +export function SchedulingSection({ + scopeBanner, + form, + setForm, + globalMaxConcurrent, + onGlobalMaxConcurrentChange, + onOverlapIgnorePathChange, + onOpenOverlapPathPicker, + onRemoveOverlapIgnorePath, + onAddOverlapIgnorePath, + onOpenWorkflowSettings, +}: SchedulingSectionProps) { + const { t } = useTranslation("app"); + return ( + <> + {scopeBanner} +

Scheduling

+
+ + { + const val = e.target.value; + onGlobalMaxConcurrentChange(val === "" ? undefined : Number(val)); + }} + /> + Maximum concurrent agents across all projects +
+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); + }} + /> +
+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState)); + }} + /> + Maximum concurrent planning agents +
+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as SettingsFormState)); + }} + /> +
+
+ + + Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure. +
+
+ + { + 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. +
+
+ + { + 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. +
+
+ + 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. +
+
+ + When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning +
+
+ + { + 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. +
+
+ + Completed tasks older than the threshold are moved out of the active task database. +
+
+ + { + 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). +
+
+ + + Compact mode keeps archive size low while preserving recent agent activity for context. +
+
+ + { + 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. +
+
+ + When enabled, tasks that modify the same files are queued serially to avoid merge conflicts +
+ +
+ + + 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) => ( +
+
+ onOverlapIgnorePathChange(index, e.target.value)} + /> + +
+ +
+ ))} +
+ +
+ +
+ +
Step Execution
+ + + ); +} + +export default SchedulingSection; diff --git a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx new file mode 100644 index 0000000000..07804417f3 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx @@ -0,0 +1,323 @@ +/** + * 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; + 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 ( + <> + {scopeBanner} +

Worktrees

+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as SettingsFormState)); + }} + /> + Limits total git worktrees including in-review tasks +
+
+ + + setForm((f) => ({ ...f, worktreeInitCommand: e.target.value })) + } + /> + Shell command to run in each new worktree after creation +
+
+ + 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 +
+
+ + + 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. + +
+
+ + + + {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, worktreesDir: e.target.value })) + } + /> + +
+ + {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 ~ and {"{repo}"}. Defaults to <projectRoot>/.worktrees when unset. Only affects newly-created worktrees. + } + +
+
+ + 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 && ( +
+ + + + Which remote to fetch for the pre-merge rebase. "Use git default" falls back to the remote configured for the default branch (typically origin). + +
+ )} +
+ + + 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

+
+ + + 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") && ( + <> + + Enable worktrunk and request approval to install the pinned release. + + )} + {worktrunkInstall.status === "pending-approval" && ( + <> + {t("settings.worktrees.awaitingApproval", "Awaiting approval — open Approvals to continue.")} + + + )} + {(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && ( + <> + {worktrunkInstall.error ?? "Worktrunk install failed."} + + + )} +
+
+ + + 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. +
+
+ + + + fail stops on worktrunk errors for explicit operator recovery; fallback-native keeps progress moving by switching to Fusion's built-in worktree backend. + +
+ + ); +} + +export default WorktreesSection;