feat(dashboard): Integration branch is a dropdown of local branches + Custom…
Replaces the plain text input with a <select> that lists the project's local branches (loaded via fetchGitBranches when the Merge section becomes visible) plus an (auto-detect) default and a Custom… option for branches that don't exist locally yet. Branch list is deduped + sorted with common integration names (main, master, trunk, develop) pinned to the top so the typical case is one click. Choosing Custom… swaps in a text input with a "Use dropdown" link to revert. A previously-saved value not in the loaded list (branch deleted locally, or initial render before fetch resolves) falls through to the custom input automatically so the operator can still see + edit it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.changeset/integration-branch-dropdown.md
Normal file
11
.changeset/integration-branch-dropdown.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
"@fusion/dashboard": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(dashboard): Integration branch setting is now a dropdown of local branches with Custom… fallback
|
||||||
|
|
||||||
|
Replaces the plain text input with a `<select>` that lists the project's local branches (loaded via `fetchGitBranches` when the Merge section becomes visible) plus an `(auto-detect — origin/HEAD → main)` default and a `Custom…` option for branches that don't exist locally yet.
|
||||||
|
|
||||||
|
Branch list is deduplicated and sorted with common integration names (`main`, `master`, `trunk`, `develop`) pinned to the top so the typical case is one click. Choosing `Custom…` swaps in a text input with a `Use dropdown` link to revert.
|
||||||
|
|
||||||
|
A previously-saved value that isn't in the loaded list (e.g. branch deleted locally, or initial load before branches fetch resolved) falls through to the custom text input automatically so the operator can still see and edit it.
|
||||||
@@ -647,6 +647,31 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline text input + secondary action ("Use dropdown") for fields that
|
||||||
|
sometimes want a free-text fallback. */
|
||||||
|
.form-inline-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
.form-inline-group .input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent, #4a90e2);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-overlap-ignore-group code {
|
.settings-overlap-ignore-group code {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
normalizeMergeAdvanceAutoSyncMode,
|
normalizeMergeAdvanceAutoSyncMode,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
||||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
|
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
|
||||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
@@ -621,6 +621,13 @@ export function SettingsModal({
|
|||||||
// Git remotes for the worktree rebase dropdown. Loaded lazily; empty list
|
// Git remotes for the worktree rebase dropdown. Loaded lazily; empty list
|
||||||
// is a valid state (fresh repo, no remotes configured yet).
|
// is a valid state (fresh repo, no remotes configured yet).
|
||||||
const [gitRemotes, setGitRemotes] = useState<GitRemoteDetailed[]>([]);
|
const [gitRemotes, setGitRemotes] = useState<GitRemoteDetailed[]>([]);
|
||||||
|
// Branch list for the Integration branch dropdown. Loaded lazily when the
|
||||||
|
// merge section becomes visible. Empty list is a valid state (fresh repo /
|
||||||
|
// permissions issue); the UI falls back to allowing custom free-text entry.
|
||||||
|
const [integrationBranchOptions, setIntegrationBranchOptions] = useState<string[]>([]);
|
||||||
|
// Sticky toggle: once the user picks "Custom..." we keep them in text-input
|
||||||
|
// mode even when the typed value happens to match an existing branch.
|
||||||
|
const [integrationBranchCustomMode, setIntegrationBranchCustomMode] = useState(false);
|
||||||
const [projectTrackingRepoOptions, setProjectTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
|
const [projectTrackingRepoOptions, setProjectTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
|
||||||
const [projectTrackingRepoLoading, setProjectTrackingRepoLoading] = useState(false);
|
const [projectTrackingRepoLoading, setProjectTrackingRepoLoading] = useState(false);
|
||||||
const [projectTrackingRepoError, setProjectTrackingRepoError] = useState<string | null>(null);
|
const [projectTrackingRepoError, setProjectTrackingRepoError] = useState<string | null>(null);
|
||||||
@@ -976,6 +983,36 @@ export function SettingsModal({
|
|||||||
.catch(() => setGitRemotes([]));
|
.catch(() => setGitRemotes([]));
|
||||||
}, [activeSection, projectId]);
|
}, [activeSection, projectId]);
|
||||||
|
|
||||||
|
// Load local branch list when the merge section becomes visible, so the
|
||||||
|
// Integration branch dropdown shows actual options instead of forcing
|
||||||
|
// free-text entry. Best-effort — falls back to empty list (custom-only).
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSection !== "merge") return;
|
||||||
|
fetchGitBranches(projectId)
|
||||||
|
.then((branches) => {
|
||||||
|
const names = branches
|
||||||
|
.map((b) => b.name)
|
||||||
|
.filter((name): name is string => typeof name === "string" && name.length > 0);
|
||||||
|
// Dedup + sort, with common integration names first.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const priority = ["main", "master", "trunk", "develop"];
|
||||||
|
const ordered: string[] = [];
|
||||||
|
for (const name of priority) {
|
||||||
|
if (names.includes(name) && !seen.has(name)) {
|
||||||
|
ordered.push(name);
|
||||||
|
seen.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const name of [...names].sort((a, b) => a.localeCompare(b))) {
|
||||||
|
if (seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
ordered.push(name);
|
||||||
|
}
|
||||||
|
setIntegrationBranchOptions(ordered);
|
||||||
|
})
|
||||||
|
.catch(() => setIntegrationBranchOptions([]));
|
||||||
|
}, [activeSection, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "general") {
|
if (activeSection !== "general") {
|
||||||
return;
|
return;
|
||||||
@@ -4619,32 +4656,83 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="integrationBranch">Integration branch</label>
|
<label htmlFor="integrationBranch">Integration branch</label>
|
||||||
<input
|
{(() => {
|
||||||
id="integrationBranch"
|
const currentValue = form.integrationBranch ?? "";
|
||||||
type="text"
|
const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue);
|
||||||
className="input"
|
const isCustomMode = integrationBranchCustomMode || (currentValue.length > 0 && !valueIsKnown);
|
||||||
placeholder="auto-detect (origin/HEAD → main)"
|
if (isCustomMode) {
|
||||||
value={form.integrationBranch ?? ""}
|
return (
|
||||||
onChange={(e) => {
|
<div className="form-inline-group">
|
||||||
const trimmed = e.target.value.trim();
|
<input
|
||||||
setForm((f) => ({
|
id="integrationBranch"
|
||||||
...f,
|
type="text"
|
||||||
integrationBranch: trimmed.length === 0 ? undefined : trimmed,
|
className="input"
|
||||||
}));
|
placeholder="branch name"
|
||||||
}}
|
value={currentValue}
|
||||||
data-testid="integration-branch-input"
|
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">
|
<details className="settings-option-details">
|
||||||
<summary>More details</summary>
|
<summary>More details</summary>
|
||||||
<small>
|
<small>
|
||||||
The canonical branch Fusion merges tasks into and uses as the reference for all
|
The canonical branch Fusion merges tasks into and uses as the reference for all
|
||||||
ahead/behind / overlap / pre-rebase computations. Leave blank to auto-resolve
|
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>integrationBranch</code> → legacy <code>baseBranch</code> →
|
||||||
<code>origin/HEAD</code> symbolic ref → fallback <code>main</code>). Set
|
<code>origin/HEAD</code> symbolic ref → fallback <code>main</code>). Pick a
|
||||||
explicitly for projects whose default branch is <code>master</code>,
|
local branch from the dropdown — common integration names like <code>main</code>,
|
||||||
<code>trunk</code>, <code>develop</code>, or any other name. Applies to both
|
<code>master</code>, <code>trunk</code>, and <code>develop</code> are listed
|
||||||
direct merges and pull-request mode; individual tasks can still override via
|
first — or choose <em>Custom…</em> to type a branch that doesn't exist
|
||||||
task metadata.
|
locally yet. Applies to both direct merges and pull-request mode; individual
|
||||||
|
tasks can still override via task metadata.
|
||||||
</small>
|
</small>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user