refactor(dashboard): extract all remaining Project sections — SettingsModal is now a shell (7,883 → 2,905 lines)

This commit is contained in:
gsxdsm
2026-06-05 01:10:35 -07:00
parent 580120e5d5
commit 1dbb7052ae
14 changed files with 3524 additions and 2868 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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}
<h4 className="settings-section-heading">General</h4>
<div className="form-group">
<label htmlFor="taskPrefix">Task Prefix</label>
<input
id="taskPrefix"
type="text"
placeholder="FN"
value={form.taskPrefix || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, taskPrefix: val || undefined }));
if (val && !/^[A-Z]{1,10}$/.test(val)) {
setPrefixError("Prefix must be 1–10 uppercase letters");
} else {
setPrefixError(null);
}
}}
/>
{prefixError && <small className="field-error">{prefixError}</small>}
{!prefixError && <small>Prefix for new task IDs (e.g. KB, PROJ)</small>}
</div>
<div className="form-group">
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast} />
<small>New tasks inherit this custom workflow's steps (overridable per task)</small>
</div>
<div className="form-group">
<label htmlFor="ephemeralAgentsEnabled" className="checkbox-label">
<input
id="ephemeralAgentsEnabled"
type="checkbox"
checked={form.ephemeralAgentsEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, ephemeralAgentsEnabled: e.target.checked }))
}
/>
Use ephemeral task-worker agents
</label>
<small>
When enabled (default), Fusion spawns short-lived <code>executor-FN-XXXX</code> agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued.
</small>
</div>
<div className="form-group">
<label htmlFor="completionDocumentationMode">Completion Documentation Automation</label>
<select
id="completionDocumentationMode"
value={form.completionDocumentationMode || "off"}
onChange={(e) =>
setForm((f) => ({
...f,
completionDocumentationMode: e.target.value as "off" | "changeset" | "changelog",
}))
}
>
<option value="off">Off</option>
<option value="changeset">Require changeset (.changeset/*.md)</option>
<option value="changelog">Require changelog update (existing changelog)</option>
</select>
<small>
Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow
<code>.changeset</code> workflows, or changelog mode when contributors should update an existing changelog file.
</small>
</div>
<div className="form-group">
<label htmlFor="showQuickChatFAB" className="checkbox-label">
<input
id="showQuickChatFAB"
type="checkbox"
checked={form.showQuickChatFAB === true}
onChange={(e) =>
setForm((f) => ({ ...f, showQuickChatFAB: e.target.checked }))
}
/>
Show quick chat button
</label>
<small>Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Chat history</h4>
<div className="form-group">
<label htmlFor="chatAutoCleanupDays">Auto-cleanup old chats</label>
<select
id="chatAutoCleanupDays"
className="select"
value={form.chatAutoCleanupDays ?? 0}
onChange={(e) =>
setForm((f) => ({ ...f, chatAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
<small>Delete chat sessions and rooms that have been idle for this many days. Default: Off.</small>
</div>
<div className="form-group">
<label htmlFor="mailAutoCleanupDays">Auto-prune old mail</label>
<select
id="mailAutoCleanupDays"
className="select"
value={form.mailAutoCleanupDays ?? 0}
onChange={(e) =>
setForm((f) => ({ ...f, mailAutoCleanupDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
<small>Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.</small>
</div>
<div className="form-group">
<label htmlFor="operationalLogRetentionDays">Operational log retention</label>
<select
id="operationalLogRetentionDays"
className="select"
value={form.operationalLogRetentionDays ?? 30}
onChange={(e) =>
setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))
}
>
<option value={0}>Off</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
<small>
Lowering this window means Reliability metrics/charts and the Activity feed will not show history older
than the selected range. Per-task task detail history is unaffected. Default: 30 days.
</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Chat Rooms</h4>
<div className="form-group">
<label htmlFor="chatRoomRecentVerbatimMessages">Recent verbatim room messages</label>
<input
id="chatRoomRecentVerbatimMessages"
type="number"
min="1"
className="input"
placeholder="25"
value={form.chatRoomRecentVerbatimMessages ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined }))
}
/>
<small>Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.</small>
</div>
<div className="form-group">
<label htmlFor="chatRoomCompactionFetchLimit">Room compaction fetch limit</label>
<input
id="chatRoomCompactionFetchLimit"
type="number"
min="1"
className="input"
placeholder="200"
value={form.chatRoomCompactionFetchLimit ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined }))
}
/>
<small>Upper bound on messages fetched from the room store for compaction consideration. Default: 200.</small>
</div>
<div className="form-group">
<label htmlFor="chatRoomSummaryMaxChars">Room summary max characters</label>
<input
id="chatRoomSummaryMaxChars"
type="number"
min="200"
className="input"
placeholder="3000"
value={form.chatRoomSummaryMaxChars ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined }))
}
/>
<small>Hard cap on the synthesized "Earlier room context" summary block. Default: 3000.</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Capacity Risk Banner</h4>
<div className="form-group">
<label htmlFor="capacityRiskBannerEnabled" className="checkbox-label">
<input
id="capacityRiskBannerEnabled"
type="checkbox"
checked={form.capacityRiskBannerEnabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, capacityRiskBannerEnabled: e.target.checked }))
}
/>
Show capacity risk banner
</label>
<small>Warn on the board when todo work exceeds the threshold and no idle agents are available.</small>
</div>
<div className="form-group">
<label htmlFor="capacityRiskTodoThresholdGeneral">Todo threshold</label>
<input
id="capacityRiskTodoThresholdGeneral"
type="number"
min={0}
className="input"
value={form.capacityRiskTodoThreshold ?? 20}
onChange={(e) =>
setForm((f) => ({
...f,
capacityRiskTodoThreshold:
e.target.value === ""
? 0
: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
}))
}
/>
<small>Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Tracking</h4>
<div className="form-group">
<label htmlFor="githubTrackingMode">Default tracking mode for new tasks</label>
<select
id="githubTrackingMode"
className="select"
value={form.githubTrackingEnabledByDefault ? "new-tasks" : "off"}
onChange={(e) =>
setForm((f) => ({
...f,
githubTrackingEnabledByDefault: e.target.value === "new-tasks",
}))
}
>
<option value="off">Off (default)</option>
<option value="new-tasks">On for new tasks</option>
</select>
<small>
Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal.
</small>
<small>
Tracking issues use this task&apos;s title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models.
{!form.autoSummarizeTitles && !form.useAiMergeCommitSummary && !form.githubTrackingEnabledByDefault
? " Enable summarization in Project Models to configure that model."
: ""}
</small>
</div>
<div className="form-group">
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">Project default tracking repo</label>
<TrackingRepoSelect
id="projectGithubTrackingDefaultRepoGeneral"
ariaLabel="Project default tracking repo"
value={form.githubTrackingDefaultRepo ?? ""}
options={projectTrackingRepoOptions}
loading={projectTrackingRepoLoading}
error={projectTrackingRepoError ?? undefined}
placeholder="owner/repo"
onChange={(nextValue) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
<small>Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.</small>
</div>
<div className="form-group">
<label htmlFor="githubTrackingDedupEnabled" className="checkbox-label">
<input
id="githubTrackingDedupEnabled"
type="checkbox"
checked={form.githubTrackingDedupEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))
}
/>
Search the tracking repo for likely duplicates before opening a new issue
</label>
<small>
When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue.
</small>
</div>
</>
);
}
export default GeneralSection;

View File

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

View File

@@ -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}
<h4 className="settings-section-heading">Merge</h4>
<div className="form-group">
<label htmlFor="autoMerge" className="checkbox-label">
<input
id="autoMerge"
type="checkbox"
checked={form.autoMerge}
onChange={(e) =>
setForm((f) => ({ ...f, autoMerge: e.target.checked }))
}
/>
Auto-merge completed tasks
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergerMode">AI merge</label>
<select
id="mergerMode"
className="select"
value={form.merger?.mode ?? "ai"}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))
}
>
<option value="ai">AI merge (default) — AI merges in a clean room, an AI reviewer audits with retries, then lands</option>
<option value="deterministic">Deterministic (legacy) — rebase / conflict-strategy / audit pipeline</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
AI mode merges the task branch into an isolated clean-room checkout at the target
branch&apos;s tip, has an AI reviewer audit the squash (with corrective retries —
advisory concerns land with a logged warning, an unfixable correctness concern
hard-fails), then fast-forwards the target branch and syncs your local checkout
(AI reconciles a conflicting restore). Each task merges to its own target branch,
or the default integration branch. <strong>The legacy merge settings below do not
apply while AI merge is on.</strong>
</small>
</details>
</div>
{(form.merger?.mode ?? "ai") === "ai" && (
<>
<div className="form-group">
<label htmlFor="mergerMaxReviewPasses">Max AI review passes</label>
<input
id="mergerMaxReviewPasses"
type="number"
min={0}
max={10}
value={form.merger?.maxReviewPasses ?? 3}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))
}
/>
<small>AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project&apos;s reviewer/validator model.</small>
</div>
<div className="form-group">
<label htmlFor="mergerAllowDirtyLocalCheckoutSync" className="checkbox-label">
<input
id="mergerAllowDirtyLocalCheckoutSync"
type="checkbox"
checked={form.merger?.allowDirtyLocalCheckoutSync === true}
onChange={(e) =>
setForm((f) => ({
...f,
merger: { ...(f.merger ?? {}), allowDirtyLocalCheckoutSync: e.target.checked },
}))
}
/>
Allow AI merge to sync a dirty checked-out integration branch
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Dangerous compatibility escape hatch. Leave off unless you explicitly want the legacy
stash → fast-forward → restore behavior when your checked-out integration branch has
unrelated local edits. When off, AI merge blocks before advancing the branch so dirty
project-root edits cannot contaminate a completed merge.
</small>
</details>
</div>
</>
)}
<div className="form-group">
<label htmlFor="testMode" className="checkbox-label">
<input
id="testMode"
type="checkbox"
checked={form.testMode === true}
onChange={(e) =>
setForm((f) => ({ ...f, testMode: e.target.checked }))
}
/>
Enable test mode
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.</small>
</details>
</div>
<MovedSettingsStub
message={t(
"settings.movedStub.reviewVerification",
"Review, verification auto-fix, and scope-enforcement settings now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
<div className="form-group">
<label htmlFor="mergeStrategy">Auto-completion mode</label>
<select
id="mergeStrategy"
value={form.mergeStrategy || "direct"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeStrategy: e.target.value as Settings["mergeStrategy"] }))
}
>
<option value="direct">Direct merge into the current branch</option>
<option value="pull-request">Create, monitor, and merge a GitHub pull request</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR.
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="integrationBranch">Integration branch</label>
{(() => {
const currentValue = form.integrationBranch ?? "";
const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue);
const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown);
if (isCustomMode) {
return (
<div className="form-inline-group">
<input
id="integrationBranch"
type="text"
className="input"
placeholder="branch name"
value={currentValue}
onChange={(e) => {
const trimmed = e.target.value.trim();
setForm((f) => ({
...f,
integrationBranch: trimmed.length === 0 ? undefined : trimmed,
}));
}}
data-testid="integration-branch-custom-input"
/>
<button
type="button"
className="btn-link"
onClick={() => {
setIntegrationBranchCustomMode(false);
setForm((f) => ({ ...f, integrationBranch: undefined }));
}}
data-testid="integration-branch-use-dropdown"
>
Use dropdown
</button>
</div>
);
}
const CUSTOM = "__fusion-custom__";
const AUTO = "";
return (
<select
id="integrationBranch"
className="select"
value={currentValue}
onChange={(e) => {
const next = e.target.value;
if (next === CUSTOM) {
setIntegrationBranchCustomMode(true);
return;
}
setForm((f) => ({
...f,
integrationBranch: next === AUTO ? undefined : next,
}));
}}
data-testid="integration-branch-select"
>
<option value={AUTO}>(auto-detect — origin/HEAD → main)</option>
{integrationBranchOptions.map((name) => (
<option key={name} value={name}>{name}</option>
))}
<option value={CUSTOM}>Custom…</option>
</select>
);
})()}
<details className="settings-option-details">
<summary>More details</summary>
<small>
The canonical branch Fusion merges tasks into and uses as the reference for all
ahead/behind / overlap / pre-rebase computations. Leave on <em>auto-detect</em>
to resolve via the standard cascade
(<code>integrationBranch</code> → legacy <code>baseBranch</code> →
<code>origin/HEAD</code> symbolic ref → fallback <code>main</code>). Pick a
local branch from the dropdown — common integration names like <code>main</code>,
<code>master</code>, <code>trunk</code>, and <code>develop</code> are listed
first — or choose <em>Custom…</em> to type a branch that doesn&apos;t exist
locally yet. Applies to both direct merges and pull-request mode; individual
tasks can still override via task metadata.
</small>
</details>
</div>
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (
<>
<div className="form-group">
<label htmlFor="directMergeCommitStrategy">Direct merge commit routing</label>
<select
id="directMergeCommitStrategy"
className="select"
value={form.directMergeCommitStrategy ?? "auto"}
onChange={(e) =>
setForm((f) => ({
...f,
directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase",
}))
}
>
<option value="auto">Auto — squash single-substantive branches, preserve multi-substantive history</option>
<option value="always-squash">Always squash direct merges</option>
<option value="always-rebase">Always preserve direct-merge commit history</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Auto keeps today&apos;s squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with <code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeIntegrationWorktree">Integration worktree</label>
<select
id="mergeIntegrationWorktree"
className="select"
value={form.mergeIntegrationWorktree ?? "reuse-task-worktree"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeIntegrationWorktree: e.target.value as Settings["mergeIntegrationWorktree"],
}))
}
>
<option value="reuse-task-worktree">Reuse task worktree (default)</option>
<option value="cwd-main">Use project root (legacy)</option>
</select>
<small>
Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk.
</small>
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (
<div
className="settings-warning-banner"
role="alert"
aria-live="polite"
data-testid="merge-integration-worktree-warning"
>
<strong>Legacy integration-branch mode.</strong>{" "}
Auto-merge will run rebase, conflict resolution, and squash commits inside the
project root (the user&apos;s checked-out integration-branch worktree) instead of
the task worktree. Fusion assumes that directory is already on the integration
branch and clean; if it isn&apos;t, merges may fail or touch the user&apos;s working
tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless
you have a specific reason to opt in (FN-5348).
</div>
)}
</div>
<div className="form-group">
<label htmlFor="mergeAdvanceAutoSync">Auto-sync project checkout after merge</label>
<select
id="mergeAdvanceAutoSync"
className="select"
value={form.mergeAdvanceAutoSync ?? "stash-and-ff"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeAdvanceAutoSync: e.target.value as "off" | "ff-only" | "stash-and-ff",
}))
}
data-testid="merge-advance-auto-sync-select"
>
<option value="stash-and-ff">Stash + fast-forward (default) — preserve local edits</option>
<option value="ff-only">Fast-forward only — skip dirty worktrees</option>
<option value="off">Off — leave the project root stale (legacy behavior)</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
After Fusion advances the integration branch ref, the merger can auto-sync other
worktrees still checked out on that branch (typically your project-root
checkout). <code>Stash + fast-forward</code> snapshots real local edits as a patch
against the previous tip, snaps the worktree to the new tip, then reapplies the
patch — untracked files that collide with newly-tracked paths are left in a temp
dir for manual recovery. <code>Fast-forward only</code> snaps cleanly when the
worktree has no edits and skips otherwise. <code>Off</code> is the legacy
behavior: <code>git status</code> in your project root will show the new commits
inverted as &quot;staged changes&quot; until you pull manually. Only applies to direct
merges.
</small>
</details>
</div>
</>
)}
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Authentication</h4>
<div className="form-group">
<label htmlFor="githubAuthMode">GitHub auth mode</label>
<select
id="githubAuthMode"
className="select"
value={form.githubAuthMode ?? "gh-cli"}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))
}
>
<option value="gh-cli">GitHub CLI (gh auth)</option>
<option value="token">Personal access token</option>
</select>
</div>
{(form.githubAuthMode ?? "gh-cli") === "token" && (
<div className="form-group">
<label htmlFor="githubAuthToken">GitHub personal access token</label>
<input
id="githubAuthToken"
type="password"
className="input"
value={form.githubAuthToken ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))
}
/>
</div>
)}
<div className="form-group">
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
<input
id="includeTaskIdInCommit"
type="checkbox"
checked={form.includeTaskIdInCommit !== false}
onChange={(e) =>
setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))
}
/>
Include task ID in commit scope
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When disabled, merge commit messages omit the task ID from the scope (e.g. <code>feat: ...</code> instead of <code>feat(KB-001): ...</code>)</small>
</details>
</div>
<div className="form-group">
<label htmlFor="commitAuthorEnabled" className="checkbox-label">
<input
id="commitAuthorEnabled"
type="checkbox"
checked={form.commitAuthorEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))
}
/>
Add Fusion as co-author on commits
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
When enabled, commits made by Fusion keep your git identity as the
primary author and append a <code>Co-authored-by</code> trailer crediting
Fusion (recognized by GitHub for shared attribution).
</small>
</details>
</div>
{form.commitAuthorEnabled !== false && (
<>
<div className="form-group">
<label htmlFor="commitAuthorName">Co-author Name</label>
<input
id="commitAuthorName"
type="text"
value={form.commitAuthorName ?? ""}
placeholder="Fusion"
onChange={(e) =>
setForm((f) => ({
...f,
commitAuthorName: e.target.value || undefined,
}))
}
/>
<small>Name used in the <code>Co-authored-by</code> trailer</small>
</div>
<div className="form-group">
<label htmlFor="commitAuthorEmail">Co-author Email</label>
<input
id="commitAuthorEmail"
type="email"
value={form.commitAuthorEmail ?? ""}
placeholder="noreply@runfusion.ai"
onChange={(e) =>
setForm((f) => ({
...f,
commitAuthorEmail: e.target.value || undefined,
}))
}
/>
<small>Email used in the <code>Co-authored-by</code> trailer</small>
</div>
</>
)}
<div className="form-group">
<label htmlFor="autoResolveConflicts" className="checkbox-label">
<input
id="autoResolveConflicts"
type="checkbox"
checked={form.autoResolveConflicts !== false}
onChange={(e) =>
setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))
}
/>
Auto-resolve conflicts in lock files and generated files
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review.</small>
</details>
</div>
{(form.merger?.mode ?? "ai") !== "ai" && (
<>
<div className="form-group">
<label htmlFor="smartConflictResolution" className="checkbox-label">
<input
id="smartConflictResolution"
type="checkbox"
checked={form.smartConflictResolution !== false}
onChange={(e) =>
setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))
}
/>
Smart conflict resolution
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeConflictStrategy">Conflict Fallback Strategy</label>
<select
id="mergeConflictStrategy"
value={form.mergeConflictStrategy ?? "smart-prefer-main"}
onChange={(e) =>
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))
}
>
<option value="smart-prefer-main">Smart, prefer main on fallback — fetch+ff origin → AI → auto-resolve → -X ours (default; protects just-merged sibling work)</option>
<option value="smart-prefer-branch">Smart, prefer task on fallback — fetch+ff origin → AI → auto-resolve → -X theirs (legacy "smart" behavior; task branch wins)</option>
<option value="ai-only">AI only — AI → auto-resolve → AI retry; never silently pick a side</option>
<option value="abort">Abort — one AI attempt; require manual resolution if it fails</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Both <strong>Smart</strong> options start with a best-effort <code>git fetch</code> + fast-forward of local main from <code>origin</code> (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the <em>final fallback</em>:
{" "}
<strong>Smart, prefer main</strong> uses <code>-X ours</code> so main wins — protects just-merged sibling work and is the new default.
{" "}
<strong>Smart, prefer task</strong> uses <code>-X theirs</code> so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression).
{" "}
<strong>AI only</strong> retries the AI agent rather than auto-picking a side.
{" "}
<strong>Abort</strong> stops after the first AI attempt and waits for a human.
{" "}
<em>Legacy <code>"smart"</code> and <code>"prefer-main"</code> values from older settings are migrated automatically.</em>
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergeStrategyOverlapBehavior">Smart Prefer Main Overlap Guard</label>
<select
id="mergeStrategyOverlapBehavior"
value={form.mergeStrategyOverlapBehavior ?? "flip-to-prefer-branch"}
onChange={(e) =>
setForm((f) => ({
...f,
mergeStrategyOverlapBehavior: e.target.value as "flip-to-prefer-branch" | "warn-only" | "ignore",
}))
}
>
<option value="flip-to-prefer-branch">Flip overlapping files to prefer the task branch (default)</option>
<option value="warn-only">Warn only — keep legacy main-wins fallback</option>
<option value="ignore">Ignore overlap detection — preserve legacy behavior</option>
</select>
<small>
When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work.
</small>
</div>
<div className="form-group">
<label htmlFor="postMergeAuditMode">Post-merge audit mode</label>
<select
className="select"
id="postMergeAuditMode"
value={form.postMergeAuditMode ?? "warn"}
onChange={(e) =>
setForm((f) => ({
...f,
postMergeAuditMode: e.target.value as "block" | "warn" | "off",
}))
}
>
<option value="block">Block (strict)</option>
<option value="warn">Warn (default; log findings, continue)</option>
<option value="off">Off (skip audit)</option>
</select>
<small>
Controls the post-merge audit gate. <strong>Warn</strong> (default) logs findings but auto-completes the merge. <strong>Block</strong> is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. <strong>Off</strong> skips the audit entirely. Switching to Off is recommended only if you trust your branches don&apos;t silently drop edits.
</small>
</div>
</>
)}
<div className="form-group">
<label htmlFor="pushAfterMerge" className="checkbox-label">
<input
id="pushAfterMerge"
type="checkbox"
checked={form.pushAfterMerge === true}
onChange={(e) =>
setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))
}
/>
Push to remote after merge
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed.</small>
</details>
</div>
{form.pushAfterMerge && (
<div className="form-group">
<label htmlFor="pushRemote">Push Remote</label>
<input
id="pushRemote"
type="text"
placeholder="origin"
value={form.pushRemote || ""}
onChange={(e) =>
setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))
}
/>
<details className="settings-option-details">
<summary>More details</summary>
<small>Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin".</small>
</details>
</div>
)}
</>
);
}
export default MergeSection;

View File

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

View File

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

View File

@@ -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<boolean>;
}
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 --- */}
<h4 className="settings-section-heading">Token Cap</h4>
<div className="form-group">
<label htmlFor="tokenCap">Token Cap</label>
<div className="settings-token-cap-row">
<input
id="tokenCap"
type="number"
placeholder="No cap"
value={form.tokenCap ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState));
}}
/>
{form.tokenCap != null && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reset to default (no cap)"
onClick={() => setForm((f) => ({ ...f, tokenCap: null } as unknown as SettingsFormState))}
style={{ whiteSpace: "nowrap" }}
>
Reset
</button>
)}
</div>
<small>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.</small>
</div>
{/* --- Project Model Lanes --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Lanes</h4>
<p className="settings-description">
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.
</p>
{modelsLoading ? (
<div className="settings-empty-state">Loading available models…</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
No models available. Configure authentication first.
</div>
) : (
<>
{projectModelLanes.map((lane) => {
const status = getLaneStatus(lane);
const value = getLaneValue(lane);
const isOverridden = status === "overridden";
const laneLabel = getProjectLaneLabel(lane);
return (
<div className="form-group" key={lane.laneId}>
<div className="settings-model-lane-label-row">
<label htmlFor={`${lane.laneId}Model`}>{laneLabel}</label>
<span
className={`settings-lane-badge ${isOverridden ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
title={isOverridden ? "Explicitly set for this project" : "Inherited from global settings"}
>
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
</span>
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown
id={`${lane.laneId}Model`}
label={laneLabel}
models={availableModels}
value={value}
onChange={(val) => updateLaneValue(lane, val)}
placeholder={lane.laneId === "default" ? "Use global default" : "Use global"}
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
{isOverridden && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reset to inherit from global"
onClick={() => resetLaneValue(lane)}
style={{ whiteSpace: "nowrap" }}
>
Reset
</button>
)}
</div>
<small>
{getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}.
</small>
</div>
);
})}
</>
)}
{/* --- Per-phase model lanes (MOVED to workflow settings) --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Per-phase model lanes</h4>
<MovedSettingsStub
message={t(
"settings.movedStub.modelLanes",
"Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
{/* --- Model Presets --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4>
<div className="form-group settings-model-presets">
<label>Configured presets</label>
{presets.length === 0 ? (
<div className="settings-empty-state settings-muted">No presets configured yet.</div>
) : (
<div className="settings-preset-list">
{presets.map((preset) => {
const selection = applyPresetToSelection(preset);
const summary = `${selection.executorValue || "default"} / ${selection.validatorValue || "default"}`;
return (
<div key={preset.id} className="settings-preset-item">
<div className="settings-preset-item-meta">
<strong>{preset.name}</strong>
<span className="settings-muted settings-preset-summary">{summary}</span>
</div>
<div className="settings-preset-item-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setEditingPresetId(preset.id);
setPresetDraft({ ...preset });
}}
>
Edit
</button>
<button
type="button"
className="btn btn-sm"
onClick={async () => {
if (inUsePresetIds.has(preset.id)) {
const shouldDelete = await confirmDelete({
title: t("settings.models.deletePresetTitle", "Delete Preset"),
message: t("settings.models.deletePresetMessage", "Preset \"{{name}}\" is used in auto-selection. Delete it anyway?", { name: preset.name }),
danger: true,
});
if (!shouldDelete) {
return;
}
}
setForm((current) => ({
...current,
modelPresets: (current.modelPresets || []).filter((entry) => entry.id !== preset.id),
defaultPresetBySize: Object.fromEntries(
Object.entries(current.defaultPresetBySize || {}).filter(([, value]) => value !== preset.id),
) as Settings["defaultPresetBySize"],
}));
if (editingPresetId === preset.id) {
setEditingPresetId(null);
setPresetDraft(null);
}
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
)}
{!presetDraft ? (
<div className="settings-preset-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setEditingPresetId(null);
setPresetDraft({ id: "", name: "", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined });
}}
>
Add Preset
</button>
</div>
) : null}
</div>
{presetDraft ? (
<div className="form-group settings-preset-editor">
<label>Preset editor</label>
<div className="settings-preset-editor-fields">
<div className="form-group">
<label htmlFor="preset-name">Name</label>
<input
id="preset-name"
type="text"
value={presetDraft.name}
onChange={(e) => {
const name = e.target.value;
setPresetDraft((current) => current ? { ...current, name } : current);
}}
/>
</div>
{availableModels.length === 0 ? (
<small>No models available. Configure authentication first.</small>
) : (
<>
<div className="form-group">
<label htmlFor="preset-executor-model">Executor model</label>
<CustomModelDropdown
id="preset-executor-model"
label="Preset executor model"
models={availableModels}
value={presetDraft.executorProvider && presetDraft.executorModelId ? `${presetDraft.executorProvider}/${presetDraft.executorModelId}` : ""}
onChange={(val) => {
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}
/>
</div>
<div className="form-group">
<label htmlFor="preset-validator-model">Reviewer model</label>
<CustomModelDropdown
id="preset-validator-model"
label="Preset reviewer model"
models={availableModels}
value={presetDraft.validatorProvider && presetDraft.validatorModelId ? `${presetDraft.validatorProvider}/${presetDraft.validatorModelId}` : ""}
onChange={(val) => {
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}
/>
</div>
</>
)}
</div>
<div className="modal-actions settings-preset-editor-actions">
<button type="button" className="btn btn-primary btn-sm" onClick={onSavePresetDraft}>{t("settings.models.savePreset", "Save preset")}</button>
<button type="button" className="btn btn-sm" onClick={() => { setEditingPresetId(null); setPresetDraft(null); }}>{t("settings.actions.cancel", "Cancel")}</button>
</div>
</div>
) : null}
<div className="form-group settings-preset-auto-select">
<label htmlFor="autoSelectModelPreset" className="checkbox-label">
<input
id="autoSelectModelPreset"
type="checkbox"
checked={form.autoSelectModelPreset || false}
onChange={(e) => setForm((current) => ({ ...current, autoSelectModelPreset: e.target.checked }))}
/>
Auto-select preset based on task size
</label>
</div>
{form.autoSelectModelPreset ? (
<div className="settings-preset-size-grid">
{(["S", "M", "L"] as const).map((sizeKey) => (
<div className="form-group settings-preset-size-row" key={sizeKey}>
<label htmlFor={`preset-size-${sizeKey}`}>
{sizeKey === "S" ? "Small tasks (S):" : sizeKey === "M" ? "Medium tasks (M):" : "Large tasks (L):"}
</label>
<select
id={`preset-size-${sizeKey}`}
value={form.defaultPresetBySize?.[sizeKey] || ""}
onChange={(e) => {
const value = e.target.value || undefined;
setForm((current) => ({
...current,
defaultPresetBySize: {
...(current.defaultPresetBySize || {}),
[sizeKey]: value,
},
}));
}}
>
<option value="">No preset</option>
{presetOptions.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
</select>
</div>
))}
</div>
) : null}
{/* --- AI Title and Git Commit Message Summarization --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">
AI Title and Git Commit Message Summarization
</h4>
<p className="settings-description">
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.
</p>
<div className="form-group">
<label htmlFor="autoSummarizeTitles" className="checkbox-label">
<input
id="autoSummarizeTitles"
type="checkbox"
checked={form.autoSummarizeTitles || false}
onChange={(e) => setForm((f) => ({ ...f, autoSummarizeTitles: e.target.checked }))}
/>
Auto-summarize long descriptions as titles
</label>
<small>
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.
</small>
</div>
<div className="form-group">
<label htmlFor="useAiMergeCommitSummary" className="checkbox-label">
<input
id="useAiMergeCommitSummary"
type="checkbox"
checked={form.useAiMergeCommitSummary || false}
onChange={(e) => setForm((f) => ({ ...f, useAiMergeCommitSummary: e.target.checked }))}
/>
AI merge commit summaries
</label>
<small>
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.
</small>
</div>
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (
<p className="settings-description">
{t(
"settings.movedStub.summarizerModelInline",
"The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.",
)}
</p>
)}
</>
);
}
export default ProjectModelsSection;

View File

@@ -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}
<h4 className="settings-section-heading">Project Research Settings</h4>
<div className="form-group">
<label htmlFor="research-project-enabled" className="checkbox-label">
<input
id="research-project-enabled"
type="checkbox"
checked={form.researchSettings?.enabled ?? true}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable research in this project
</label>
</div>
<div className="form-group">
<label>Enabled Sources</label>
<label
htmlFor="research-project-source-webSearch"
className="checkbox-label settings-research-source-locked"
>
<input id="research-project-source-webSearch" type="checkbox" checked disabled readOnly />
Web Search <span className="settings-muted">Always on</span>
</label>
<small className="settings-muted">
Web search is always enabled. Configure the search provider under Research Defaults.
</small>
<div className="settings-research-source-grid">
{[
["pageFetch", "Page Fetch"],
["github", "GitHub"],
["localDocs", "Local Docs"],
["llmSynthesis", "LLM Synthesis"],
].map(([key, label]) => (
<label key={key} htmlFor={`research-project-source-${key}`} className="checkbox-label">
<input
id={`research-project-source-${key}`}
type="checkbox"
checked={sources?.[key as keyof NonNullable<typeof sources>] ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
enabledSources: {
...(current.researchSettings?.enabledSources ?? {}),
[key]: event.target.checked,
},
},
}))
}
/>
{label}
</label>
))}
</div>
</div>
<div className="form-group">
<div className="settings-research-limits-grid">
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-concurrent">Max Concurrent Runs</label>
<input
id="research-project-max-concurrent"
className="input"
type="number"
min={1}
value={limits?.maxConcurrentRuns ?? 3}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-sources">Max Sources Per Run</label>
<input
id="research-project-max-sources"
className="input"
type="number"
min={1}
value={limits?.maxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-max-duration">Max Duration (ms)</label>
<input
id="research-project-max-duration"
className="input"
type="number"
min={1000}
value={limits?.maxDurationMs ?? 300000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
maxDurationMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-project-request-timeout">Request Timeout (ms)</label>
<input
id="research-project-request-timeout"
className="input"
type="number"
min={1000}
value={limits?.requestTimeoutMs ?? 30000}
onChange={(event) =>
setForm((current) => ({
...current,
researchSettings: {
...(current.researchSettings ?? {}),
limits: {
...(current.researchSettings?.limits ?? {}),
requestTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
},
},
}))
}
/>
</div>
{researchLimitError && <small className="field-error settings-research-limits-error">{researchLimitError}</small>}
</div>
</div>
</>
);
}
export default ResearchProjectSection;

View File

@@ -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}
<h4 className="settings-section-heading">Scheduled Evals</h4>
<div className="form-group">
<label htmlFor="scheduled-evals-enabled" className="checkbox-label">
<input
id="scheduled-evals-enabled"
type="checkbox"
checked={isScheduledEvalEnabled}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
enabled: event.target.checked,
},
}))
}
/>
Enable scheduled eval runs for this project
</label>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-interval">Interval (ms)</label>
<input
id="scheduled-evals-interval"
className="input"
type="number"
min={60000}
max={604800000}
step={1000}
disabled={!isScheduledEvalEnabled}
value={evalSettings.intervalMs ?? 86_400_000}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
intervalMs: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-provider">Evaluator Provider</label>
<input
id="scheduled-evals-provider"
className="input"
value={evalSettings.evaluatorProvider ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="openai"
/>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-model">Evaluator Model</label>
<input
id="scheduled-evals-model"
className="input"
value={evalSettings.evaluatorModelId ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
evaluatorModelId: event.target.value.trim() === "" ? undefined : event.target.value,
},
}))
}
placeholder="gpt-5"
/>
<small className="form-text text-muted">
Leave provider and model blank to inherit the project validator lane model settings.
</small>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-follow-up-policy">Follow-up Policy</label>
<select
id="scheduled-evals-follow-up-policy"
className="select"
disabled={!isScheduledEvalEnabled}
value={evalSettings.followUpPolicy ?? "suggest-only"}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
followUpPolicy: event.target.value as "disabled" | "suggest-only" | "auto-create",
},
}))
}
>
<option value="disabled">Disabled</option>
<option value="suggest-only">Suggest only</option>
<option value="auto-create">Auto-create tasks</option>
</select>
</div>
<div className="form-group">
<label htmlFor="scheduled-evals-retention-days">Retention (days)</label>
<input
id="scheduled-evals-retention-days"
className="input"
type="number"
min={1}
max={365}
step={1}
disabled={!isScheduledEvalEnabled}
value={evalSettings.retentionDays ?? 30}
onChange={(event) =>
setForm((current) => ({
...current,
evalSettings: {
...(current.evalSettings ?? {}),
retentionDays: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
</div>
</>
);
}
export default ScheduledEvalsSection;

View File

@@ -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}
<h4 className="settings-section-heading">Scheduling</h4>
<div className="form-group">
<label htmlFor="globalMaxConcurrent">Global Max Concurrent</label>
<input
id="globalMaxConcurrent"
type="number"
min={0}
max={10000}
value={globalMaxConcurrent ?? ""}
onChange={(e) => {
const val = e.target.value;
onGlobalMaxConcurrentChange(val === "" ? undefined : Number(val));
}}
/>
<small className="form-text text-muted">Maximum concurrent agents across all projects</small>
</div>
<div className="form-group">
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
<input
id="maxConcurrent"
type="number"
min={1}
max={10}
value={form.maxConcurrent ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
</div>
<div className="form-group">
<label htmlFor="maxTriageConcurrent">Max Triage Concurrent</label>
<input
id="maxTriageConcurrent"
type="number"
min={1}
max={10}
value={form.maxTriageConcurrent ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
<small>Maximum concurrent planning agents</small>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
<input
id="pollIntervalMs"
type="number"
min={5000}
step={1000}
value={form.pollIntervalMs ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
</div>
<div className="form-group">
<label htmlFor="heartbeatScopeDiscipline">Heartbeat Scope Discipline</label>
<select
id="heartbeatScopeDiscipline"
className="select"
value={form.heartbeatScopeDiscipline ?? "strict"}
onChange={(e) => {
setForm((f) => ({
...f,
heartbeatScopeDiscipline: e.target.value as "strict" | "lite" | "off",
}));
}}
>
<option value="strict">Strict (default)</option>
<option value="lite">Lite</option>
<option value="off">Off</option>
</select>
<small>Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.</small>
</div>
<div className="form-group">
<label htmlFor="taskStuckTimeoutMs">Stuck Task Timeout (minutes)</label>
<input
id="taskStuckTimeoutMs"
type="number"
min={1}
step={1}
value={form.taskStuckTimeoutMs ? Math.round(form.taskStuckTimeoutMs / 60000) : ""}
onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
}}
/>
<small>Timeout in minutes for detecting stuck tasks. When a task&apos;s agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
</div>
<div className="form-group">
<label htmlFor="staleHighFanoutBlockerAgeThresholdMs">Stale High Fan-out Escalation (hours)</label>
<input
id="staleHighFanoutBlockerAgeThresholdMs"
type="number"
min={1}
step={1}
value={form.staleHighFanoutBlockerAgeThresholdMs ? Math.round(form.staleHighFanoutBlockerAgeThresholdMs / 3600000) : ""}
onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({
...f,
staleHighFanoutBlockerAgeThresholdMs: val && num > 0 ? num * 3600000 : undefined,
}));
}}
/>
<small>Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.</small>
</div>
<div className="form-group">
<label htmlFor="preserveProgressOnStuckRequeue" className="checkbox-label">
<input
id="preserveProgressOnStuckRequeue"
type="checkbox"
checked={form.preserveProgressOnStuckRequeue !== false}
onChange={(e) =>
setForm((f) => ({ ...f, preserveProgressOnStuckRequeue: e.target.checked }))
}
/>
Preserve step progress on stuck-task requeue
</label>
<small>When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled.</small>
</div>
<div className="form-group">
<label htmlFor="specStalenessEnabled" className="checkbox-label">
<input
id="specStalenessEnabled"
type="checkbox"
checked={form.specStalenessEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))
}
/>
Enable plan staleness enforcement
</label>
<small>When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning</small>
</div>
<div className="form-group">
<label htmlFor="specStalenessMaxAgeMs">Stale Spec Threshold (hours)</label>
<input
id="specStalenessMaxAgeMs"
type="number"
min={0}
step={1}
value={form.specStalenessMaxAgeMs !== undefined ? Math.round(form.specStalenessMaxAgeMs / 3600000) : ""}
onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined }));
}}
disabled={!form.specStalenessEnabled}
/>
<small>Maximum age in hours before a plan is considered stale. Default: 6 hours.</small>
</div>
<div className="form-group">
<label htmlFor="autoArchiveDoneTasksEnabled" className="checkbox-label">
<input
id="autoArchiveDoneTasksEnabled"
type="checkbox"
checked={form.autoArchiveDoneTasksEnabled ?? true}
onChange={(e) =>
setForm((f) => ({
...f,
autoArchiveDoneTasksEnabled: e.target.checked,
}))
}
/>
Enable automatic task archiving
</label>
<small>Completed tasks older than the threshold are moved out of the active task database.</small>
</div>
<div className="form-group">
<label htmlFor="autoArchiveDoneAfterMs">Archive Completed Tasks After (days)</label>
<input
id="autoArchiveDoneAfterMs"
type="number"
min={1}
step={1}
value={form.autoArchiveDoneAfterMs !== undefined ? Math.round(form.autoArchiveDoneAfterMs / MS_PER_DAY) : AUTO_ARCHIVE_DEFAULT_AFTER_DAYS}
onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({
...f,
autoArchiveDoneAfterMs: val === "" ? undefined : num * MS_PER_DAY,
}));
}}
disabled={form.autoArchiveDoneTasksEnabled === false}
/>
<small>Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).</small>
</div>
<div className="form-group">
<label htmlFor="archiveAgentLogMode">Archive Agent Log</label>
<select
id="archiveAgentLogMode"
value={form.archiveAgentLogMode ?? "compact"}
onChange={(e) =>
setForm((f) => ({
...f,
archiveAgentLogMode: e.target.value as "none" | "compact" | "full",
}))
}
disabled={form.autoArchiveDoneTasksEnabled === false}
>
<option value="compact">Compact summary and recent entries</option>
<option value="none">Do not archive agent logs</option>
<option value="full">Full agent log</option>
</select>
<small>Compact mode keeps archive size low while preserving recent agent activity for context.</small>
</div>
<div className="form-group">
<label htmlFor="maxStuckKills">Max Stuck Retries</label>
<input
id="maxStuckKills"
type="number"
min={1}
step={1}
value={form.maxStuckKills ?? ""}
onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({ ...f, maxStuckKills: val && num > 0 ? num : undefined }));
}}
/>
<small>Maximum stuck-detector retries before a task is marked failed. Default: 6.</small>
</div>
<div className="form-group">
<label htmlFor="groupOverlappingFiles" className="checkbox-label">
<input
id="groupOverlappingFiles"
type="checkbox"
checked={form.groupOverlappingFiles}
onChange={(e) =>
setForm((f) => ({ ...f, groupOverlappingFiles: e.target.checked }))
}
/>
Serialize tasks with overlapping files
</label>
<small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small>
</div>
<div className="form-group settings-overlap-ignore-group">
<label>Ignored overlap paths</label>
<small>
Optional file or directory paths to ignore when overlap serialization is enabled.
Paths are project-relative (for example <code>docs/</code> or <code>generated/*</code>).
</small>
<div className="settings-overlap-ignore-list">
{(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => (
<div key={`overlap-ignore-${index}`} className="settings-overlap-ignore-row">
<div className="settings-overlap-ignore-path-controls">
<input
type="text"
value={path}
placeholder="docs/"
onChange={(e) => onOverlapIgnorePathChange(index, e.target.value)}
/>
<button
type="button"
className="btn btn-sm"
onClick={() => onOpenOverlapPathPicker(index)}
aria-label={`Browse path for ignored overlap entry ${index + 1}`}
>
Browse
</button>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => onRemoveOverlapIgnorePath(index)}
disabled={(form.overlapIgnorePaths ?? []).length === 0 && index === 0}
>
Remove
</button>
</div>
))}
</div>
<button
type="button"
className="btn btn-sm"
onClick={onAddOverlapIgnorePath}
>
Add ignored path
</button>
</div>
<div className="settings-section-divider" />
<h5 className="settings-section-heading">Step Execution</h5>
<MovedSettingsStub
message={t(
"settings.movedStub.stepExecution",
"Step execution settings (run steps in new sessions, max parallel steps) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
</>
);
}
export default SchedulingSection;

View File

@@ -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<typeof useWorktrunkInstallStatus>;
worktrunkInstallVerified: boolean;
onOpenWorktreesDirPicker: () => void;
onOpenApprovals?: (approvalId?: string) => void;
}
export function WorktreesSection({
scopeBanner,
form,
setForm,
gitRemotes,
worktrunkInstall,
worktrunkInstallVerified,
onOpenWorktreesDirPicker,
onOpenApprovals,
}: WorktreesSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Worktrees</h4>
<div className="form-group">
<label htmlFor="maxWorktrees">Max Worktrees</label>
<input
id="maxWorktrees"
type="number"
min={1}
max={20}
value={form.maxWorktrees ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as SettingsFormState));
}}
/>
<small>Limits total git worktrees including in-review tasks</small>
</div>
<div className="form-group">
<label htmlFor="worktreeInitCommand">Worktree Init Command</label>
<input
id="worktreeInitCommand"
type="text"
placeholder="pnpm install --frozen-lockfile"
value={form.worktreeInitCommand || ""}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeInitCommand: e.target.value }))
}
/>
<small>Shell command to run in each new worktree after creation</small>
</div>
<div className="form-group">
<label htmlFor="recycleWorktrees" className="checkbox-label">
<input
id="recycleWorktrees"
type="checkbox"
checked={form.recycleWorktrees}
onChange={(e) =>
setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))
}
/>
Recycle worktrees
</label>
<small>Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup</small>
</div>
<div className="form-group">
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
<input
id="executorAllowSiblingBranchRename"
type="checkbox"
checked={form.executorAllowSiblingBranchRename === true}
onChange={(e) =>
setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))
}
/>
Allow silent sibling branch rename during executor conflicts
</label>
<small>
Discouraged. This restores the legacy behavior where a live <code>fusion/&lt;task-id&gt;</code> branch collision silently forks work onto sibling branches like <code>-2</code> and can hide prior commits from the default recovery flow.
</small>
</div>
<div className="form-group">
<label htmlFor="worktreeNaming">Worktree Naming Style</label>
<select
id="worktreeNaming"
value={form.worktreeNaming || "random"}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeNaming: e.target.value as "random" | "task-id" | "task-title" }))
}
disabled={form.recycleWorktrees}
>
<option value="random">Random names (e.g., swift-falcon)</option>
<option value="task-id">Task ID (e.g., FN-042)</option>
<option value="task-title">Task title (e.g., fix-login-bug)</option>
</select>
<small>
{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."}
</small>
</div>
<div className="form-group">
<label htmlFor="worktreesDir">Worktrees Directory</label>
<div className="settings-overlap-ignore-path-controls">
<input
id="worktreesDir"
type="text"
placeholder="Defaults to .worktrees — leave empty unless overriding"
value={form.worktreesDir || ""}
disabled={form.worktrunk?.enabled === true}
onChange={(e) =>
setForm((f) => ({ ...f, worktreesDir: e.target.value }))
}
/>
<button
type="button"
className="btn btn-sm"
onClick={onOpenWorktreesDirPicker}
aria-label="Browse worktrees directory"
disabled={form.worktrunk?.enabled === true}
>
Browse
</button>
</div>
<small>
{form.worktrunk?.enabled === true
? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory."
: <>
Optional. Supports <code>~</code> and <code>{"{repo}"}</code>. Defaults to <code>&lt;projectRoot&gt;/.worktrees</code> when unset. Only affects newly-created worktrees.
</>}
</small>
</div>
<div className="form-group">
<label htmlFor="worktreeRebaseBeforeMerge" className="checkbox-label">
<input
id="worktreeRebaseBeforeMerge"
type="checkbox"
checked={form.worktreeRebaseBeforeMerge !== false}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: e.target.checked }))
}
/>
Rebase from remote before merge
</label>
<small>When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging — catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline.</small>
</div>
{form.worktreeRebaseBeforeMerge !== false && (
<div className="form-group">
<label htmlFor="worktreeRebaseRemote">Rebase Remote</label>
<select
id="worktreeRebaseRemote"
value={form.worktreeRebaseRemote ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseRemote: e.target.value || undefined }))
}
>
<option value="">Use git default</option>
{gitRemotes.map((remote) => (
<option key={remote.name} value={remote.name}>
{remote.name} ({remote.fetchUrl})
</option>
))}
</select>
<small>
Which remote to fetch for the pre-merge rebase. "Use git default" falls back to the remote configured for the default branch (typically <code>origin</code>).
</small>
</div>
)}
<div className="form-group">
<label htmlFor="worktreeRebaseLocalBase" className="checkbox-label">
<input
id="worktreeRebaseLocalBase"
type="checkbox"
checked={form.worktreeRebaseLocalBase !== false}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeRebaseLocalBase: e.target.checked }))
}
/>
Also rebase onto local default-branch HEAD
</label>
<small>
In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow.
</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Worktrunk integration</h4>
<div className="form-group">
<label htmlFor="worktrunkEnabled" className="checkbox-label">
<input
id="worktrunkEnabled"
type="checkbox"
checked={form.worktrunk?.enabled === true}
disabled={!worktrunkInstallVerified && form.worktrunk?.enabled !== true}
onChange={(e) =>
setForm((f) => ({
...f,
worktrunk: {
enabled: e.target.checked,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))
}
/>
Enable worktrunk integration
</label>
<small>
Disabled by default (opt-in). When enabled, Fusion shells out to <code>worktrunk</code> for worktree create, sync, prune, and remove operations and follows worktrunk&apos;s directory layout.
</small>
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (
<small className="settings-muted">Install the worktrunk binary below to enable this integration.</small>
)}
</div>
<div className="form-group" data-testid="worktrunk-install-affordance">
{worktrunkInstall.status === "installed" && (
<small className="settings-muted">
worktrunk {worktrunkInstall.version ?? ""} installed at {worktrunkInstall.installPath ?? "~/.fusion/bin/worktrunk"}
</small>
)}
{(worktrunkInstall.status === "missing" || worktrunkInstall.status === "installing") && (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => void worktrunkInstall.requestInstall()}
disabled={worktrunkInstall.requesting || worktrunkInstall.status === "installing"}
>
{t("settings.worktrees.installWorktrunk", "Install worktrunk binary")}
</button>
<small className="settings-muted">Enable worktrunk and request approval to install the pinned release.</small>
</>
)}
{worktrunkInstall.status === "pending-approval" && (
<>
<small className="settings-muted">{t("settings.worktrees.awaitingApproval", "Awaiting approval — open Approvals to continue.")}</small>
<button
type="button"
className="btn btn-secondary"
onClick={() => onOpenApprovals?.(worktrunkInstall.pendingApprovalId)}
>
{t("settings.worktrees.openApprovals", "Open Approvals")}
</button>
</>
)}
{(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && (
<>
<small style={{ color: "var(--color-error)" }}>{worktrunkInstall.error ?? "Worktrunk install failed."}</small>
<button type="button" className="btn btn-secondary" onClick={() => void worktrunkInstall.requestInstall()}>
{t("settings.worktrees.tryAgain", "Try again")}
</button>
</>
)}
</div>
<div className="form-group">
<label htmlFor="worktrunkBinaryPath">Worktrunk binary path</label>
<input
id="worktrunkBinaryPath"
type="text"
className="input"
placeholder="auto-detect (~/.fusion/bin/worktrunk or $PATH)"
value={form.worktrunk?.binaryPath ?? ""}
disabled={form.worktrunk?.enabled !== true}
onChange={(e) =>
setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: e.target.value,
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))
}
/>
<small>Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.</small>
</div>
<div className="form-group">
<label htmlFor="worktrunkOnFailure">Worktrunk failure behavior</label>
<select
id="worktrunkOnFailure"
className="select"
value={form.worktrunk?.onFailure ?? "fail"}
disabled={form.worktrunk?.enabled !== true}
onChange={(e) =>
setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: e.target.value as "fail" | "fallback-native",
},
}))
}
>
<option value="fail">Fail and pause the task (default)</option>
<option value="fallback-native">Fall back to Fusion's native worktree backend</option>
</select>
<small>
<code>fail</code> stops on worktrunk errors for explicit operator recovery; <code>fallback-native</code> keeps progress moving by switching to Fusion&apos;s built-in worktree backend.
</small>
</div>
</>
);
}
export default WorktreesSection;