feat(FN-4731): add tracking repo dropdown to settings modal

Adds a `TrackingRepoSelect` dropdown component to the SettingsModal (with co-located CSS and full test coverage), updates the settings reference docs, and includes the FN-4731 changeset for the `@runfusion/fusion` package.

Fusion-Task-Id: FN-4731
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 09:14:55 -07:00
committed by gsxdsm
parent e5c9c7a3d9
commit 51c60fe1b5
8 changed files with 483 additions and 26 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Settings now provide a tracking-repo picker for both project and global defaults, populated from detected GitHub remotes with a Custom fallback for manual `owner/repo` entry.

View File

@@ -68,7 +68,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
| `openrouterProviderPreferences` | `{ order?: string[]; ignore?: string[]; only?: string[]; allow_fallbacks?: boolean; sort?: "price" \| "throughput" \| "latency"; require_parameters?: boolean }` | `undefined` | Optional OpenRouter provider routing preferences forwarded via `compat.openRouterRouting` on chat-completion requests. See OpenRouter provider routing: <https://openrouter.ai/docs/features/provider-routing>. | | `openrouterProviderPreferences` | `{ order?: string[]; ignore?: string[]; only?: string[]; allow_fallbacks?: boolean; sort?: "price" \| "throughput" \| "latency"; require_parameters?: boolean }` | `undefined` | Optional OpenRouter provider routing preferences forwarded via `compat.openRouterRouting` on chat-completion requests. See OpenRouter provider routing: <https://openrouter.ai/docs/features/provider-routing>. |
| `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. | | `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. |
| `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. | | `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. This key is dual-scope: global saves go through `PUT /api/settings/global` (Settings → Global General). | | `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: global saves go through `PUT /api/settings/global` (Settings → Global General). |
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. | | `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. | | `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. | | `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
@@ -340,7 +340,7 @@ Default notes:
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. | | `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. |
| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. | | `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. |
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. Even when this is false, issue creation can still occur per task if tracking is explicitly enabled. | | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. Even when this is false, issue creation can still occur per task if tracking is explicitly enabled. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). | | `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). |
| `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking lifecycle integration. `"gh-cli"` requires an installed/authenticated `gh` CLI. `"token"` requires a non-empty `githubAuthToken` (or `GITHUB_TOKEN` env fallback). Tracking lifecycle auth is strict per selected mode (no cross-fallback). | | `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking lifecycle integration. `"gh-cli"` requires an installed/authenticated `gh` CLI. `"token"` requires a non-empty `githubAuthToken` (or `GITHUB_TOKEN` env fallback). Tracking lifecycle auth is strict per selected mode (no cross-fallback). |
| `githubAuthToken` | `string` | `undefined` | Optional project PAT used when `githubAuthMode` is `"token"` (takes precedence over server startup token for tracking flows). | | `githubAuthToken` | `string` | `undefined` | Optional project PAT used when `githubAuthMode` is `"token"` (takes precedence over server startup token for tracking flows). |
| `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. | | `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. |

View File

@@ -12,8 +12,8 @@ import {
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
} 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, fetchGitRemotesDetailed, 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, 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, GitRemoteDetailed, 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";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
@@ -51,6 +51,7 @@ import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode"; import { useViewportMode } from "../hooks/useViewportMode";
import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus"; import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus";
import { NodeHealthDot } from "./NodeHealthDot"; import { NodeHealthDot } from "./NodeHealthDot";
import { TrackingRepoSelect, type TrackingRepoOption } from "./TrackingRepoSelect";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility"; import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -74,6 +75,17 @@ function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error
return "Offline"; return "Offline";
} }
function toTrackingRepoOptions(remotes: GitRemote[]): TrackingRepoOption[] {
const byValue = new Map<string, TrackingRepoOption>();
for (const remote of remotes) {
const value = `${remote.owner}/${remote.repo}`;
if (!byValue.has(value)) {
byValue.set(value, { value, label: value });
}
}
return [...byValue.values()].sort((a, b) => a.value.localeCompare(b.value));
}
/** /**
* Has the user already clicked the "Star on GitHub" button at any point in * Has the user already clicked the "Star on GitHub" button at any point in
* the past? Used to permanently hide the button afterward — clicking opens * the past? Used to permanently hide the button afterward — clicking opens
@@ -595,6 +607,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[]>([]);
const [projectTrackingRepoOptions, setProjectTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
const [projectTrackingRepoLoading, setProjectTrackingRepoLoading] = useState(false);
const [projectTrackingRepoError, setProjectTrackingRepoError] = useState<string | null>(null);
const [globalTrackingRepoOptions, setGlobalTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
const [globalTrackingRepoLoading, setGlobalTrackingRepoLoading] = useState(false);
const [globalTrackingRepoError, setGlobalTrackingRepoError] = useState<string | null>(null);
const globalTrackingRepoLoadedRef = useRef(false);
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]); const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
const [selectedMemoryPath, setSelectedMemoryPath] = useState(DEFAULT_MEMORY_EDITOR_PATH); const [selectedMemoryPath, setSelectedMemoryPath] = useState(DEFAULT_MEMORY_EDITOR_PATH);
const [memoryTestQuery, setMemoryTestQuery] = useState(""); const [memoryTestQuery, setMemoryTestQuery] = useState("");
@@ -927,6 +946,109 @@ export function SettingsModal({
.catch(() => setGitRemotes([])); .catch(() => setGitRemotes([]));
}, [activeSection, projectId]); }, [activeSection, projectId]);
useEffect(() => {
if (activeSection !== "general") {
return;
}
let cancelled = false;
setProjectTrackingRepoLoading(true);
setProjectTrackingRepoError(null);
fetchGitRemotes(projectId)
.then((remotes) => {
if (cancelled) {
return;
}
setProjectTrackingRepoOptions(toTrackingRepoOptions(remotes));
})
.catch(() => {
if (cancelled) {
return;
}
setProjectTrackingRepoOptions([]);
setProjectTrackingRepoError("Could not load detected remotes. Enter a custom owner/repo value.");
})
.finally(() => {
if (!cancelled) {
setProjectTrackingRepoLoading(false);
}
});
return () => {
cancelled = true;
};
}, [activeSection, projectId]);
useEffect(() => {
if (activeSection !== "global-general" || globalTrackingRepoLoadedRef.current) {
return;
}
let cancelled = false;
setGlobalTrackingRepoLoading(true);
setGlobalTrackingRepoError(null);
fetchProjects()
.then(async (projects) => {
const results = await Promise.allSettled(
projects.map(async (project: ProjectInfo) => {
const remotes = await fetchGitRemotes(project.id);
return remotes.map((remote) => ({
value: `${remote.owner}/${remote.repo}`,
label: `${remote.owner}/${remote.repo}`,
source: project.name,
}));
}),
);
if (cancelled) {
return;
}
const optionsByValue = new Map<string, TrackingRepoOption>();
let successCount = 0;
for (const result of results) {
if (result.status !== "fulfilled") {
continue;
}
successCount += 1;
for (const option of result.value) {
if (!optionsByValue.has(option.value)) {
optionsByValue.set(option.value, option);
}
}
}
const flattenedOptions = [...optionsByValue.values()].sort((a, b) => a.value.localeCompare(b.value));
setGlobalTrackingRepoOptions(flattenedOptions);
if (projects.length > 0 && successCount === 0) {
setGlobalTrackingRepoError("Could not load remotes from registered projects. Enter a custom owner/repo value.");
}
globalTrackingRepoLoadedRef.current = true;
})
.catch(() => {
if (cancelled) {
return;
}
setGlobalTrackingRepoOptions([]);
setGlobalTrackingRepoError("Could not load project list. Enter a custom owner/repo value.");
globalTrackingRepoLoadedRef.current = true;
})
.finally(() => {
if (!cancelled) {
setGlobalTrackingRepoLoading(false);
}
});
return () => {
cancelled = true;
};
}, [activeSection, projectId]);
useEffect(() => { useEffect(() => {
if (activeSection !== "memory" || memoryDirty) { if (activeSection !== "memory" || memoryDirty) {
return; return;
@@ -2279,14 +2401,16 @@ export function SettingsModal({
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">Project default tracking repo</label> <label htmlFor="projectGithubTrackingDefaultRepoGeneral">Project default tracking repo</label>
<input <TrackingRepoSelect
id="projectGithubTrackingDefaultRepoGeneral" id="projectGithubTrackingDefaultRepoGeneral"
type="text" ariaLabel="Project default tracking repo"
className="input"
placeholder="owner/repo"
value={form.githubTrackingDefaultRepo ?? ""} value={form.githubTrackingDefaultRepo ?? ""}
onChange={(e) => options={projectTrackingRepoOptions}
setForm((f) => ({ ...f, githubTrackingDefaultRepo: e.target.value || undefined })) 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> <small>Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.</small>
@@ -2317,14 +2441,16 @@ export function SettingsModal({
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="globalGithubTrackingDefaultRepo">Global default tracking repo</label> <label htmlFor="globalGithubTrackingDefaultRepo">Global default tracking repo</label>
<input <TrackingRepoSelect
id="globalGithubTrackingDefaultRepo" id="globalGithubTrackingDefaultRepo"
type="text" ariaLabel="Global default tracking repo"
className="input"
placeholder="owner/repo"
value={form.githubTrackingDefaultRepo ?? ""} value={form.githubTrackingDefaultRepo ?? ""}
onChange={(e) => options={globalTrackingRepoOptions}
setForm((f) => ({ ...f, githubTrackingDefaultRepo: e.target.value || undefined })) loading={globalTrackingRepoLoading}
error={globalTrackingRepoError ?? undefined}
placeholder="owner/repo"
onChange={(nextValue) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
} }
/> />
<small>Projects inherit this value when they do not set a project default tracking repo.</small> <small>Projects inherit this value when they do not set a project default tracking repo.</small>

View File

@@ -0,0 +1,23 @@
.tracking-repo-select {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tracking-repo-select__custom-input {
margin-top: 0;
}
.tracking-repo-select__hint {
color: var(--text-muted);
}
.tracking-repo-select__hint--error {
color: var(--color-error);
}
@media (max-width: 768px) {
.tracking-repo-select {
gap: var(--space-xs);
}
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import "./TrackingRepoSelect.css";
const CUSTOM_VALUE = "__custom__";
export interface TrackingRepoOption {
value: string;
label: string;
source?: string;
}
interface TrackingRepoSelectProps {
id: string;
value: string;
onChange: (value: string) => void;
options: TrackingRepoOption[];
loading?: boolean;
error?: string;
placeholder?: string;
ariaLabel?: string;
}
function normalizeOptions(options: TrackingRepoOption[]): TrackingRepoOption[] {
const byValue = new Map<string, TrackingRepoOption>();
for (const option of options) {
const normalized = option.value.trim();
if (!normalized || byValue.has(normalized)) {
continue;
}
byValue.set(normalized, {
...option,
value: normalized,
label: option.label?.trim() || normalized,
});
}
return [...byValue.values()].sort((a, b) => a.value.localeCompare(b.value));
}
export function TrackingRepoSelect({
id,
value,
onChange,
options,
loading = false,
error,
placeholder = "owner/repo",
ariaLabel,
}: TrackingRepoSelectProps) {
const customInputRef = useRef<HTMLInputElement>(null);
const hintId = useId();
const [showCustomInput, setShowCustomInput] = useState(false);
const normalizedOptions = useMemo(() => normalizeOptions(options), [options]);
const selectedOption = normalizedOptions.find((option) => option.value === value);
const selectValue = selectedOption ? selectedOption.value : CUSTOM_VALUE;
useEffect(() => {
setShowCustomInput(selectValue === CUSTOM_VALUE);
}, [selectValue]);
const handleSelectChange = (nextValue: string) => {
if (nextValue === CUSTOM_VALUE) {
setShowCustomInput(true);
requestAnimationFrame(() => {
customInputRef.current?.focus();
});
return;
}
setShowCustomInput(false);
onChange(nextValue);
};
const renderedHint = error ? error : loading ? "Loading detected GitHub remotes…" : null;
return (
<div className="tracking-repo-select">
<select
id={id}
className="select"
value={selectValue}
onChange={(event) => handleSelectChange(event.target.value)}
aria-label={ariaLabel}
aria-describedby={renderedHint ? hintId : undefined}
>
{normalizedOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.source ? `${option.label} — ${option.source}` : option.label}
</option>
))}
<option value={CUSTOM_VALUE}>Custom…</option>
</select>
{showCustomInput ? (
<input
ref={customInputRef}
className="input tracking-repo-select__custom-input"
type="text"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
aria-describedby={renderedHint ? hintId : undefined}
/>
) : null}
{renderedHint ? (
<small
id={hintId}
className={error ? "tracking-repo-select__hint tracking-repo-select__hint--error" : "tracking-repo-select__hint"}
>
{renderedHint}
</small>
) : null}
</div>
);
}

View File

@@ -38,7 +38,9 @@ const mockUpdateGlobalConcurrency = vi.fn();
const mockFetchMemoryBackendStatus = vi.fn(); const mockFetchMemoryBackendStatus = vi.fn();
const mockTestMemoryRetrieval = vi.fn(); const mockTestMemoryRetrieval = vi.fn();
const mockInstallQmd = vi.fn(); const mockInstallQmd = vi.fn();
const mockFetchGitRemotes = vi.fn();
const mockFetchGitRemotesDetailed = vi.fn(); const mockFetchGitRemotesDetailed = vi.fn();
const mockFetchProjects = vi.fn();
const mockFetchDashboardHealth = vi.fn(); const mockFetchDashboardHealth = vi.fn();
const mockCheckForUpdates = vi.fn(); const mockCheckForUpdates = vi.fn();
const mockFetchRemoteSettings = vi.fn(); const mockFetchRemoteSettings = vi.fn();
@@ -95,7 +97,9 @@ vi.mock("../../api", async (importOriginal) => {
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args), fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args), testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
installQmd: (...args: unknown[]) => mockInstallQmd(...args), installQmd: (...args: unknown[]) => mockInstallQmd(...args),
fetchGitRemotes: (...args: unknown[]) => mockFetchGitRemotes(...args),
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args), fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
fetchProjects: (...args: unknown[]) => mockFetchProjects(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args), fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args), checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args), fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
@@ -409,7 +413,9 @@ describe("SettingsModal", () => {
qmdAvailable: true, qmdAvailable: true,
qmdInstallCommand: "bun install -g @tobilu/qmd", qmdInstallCommand: "bun install -g @tobilu/qmd",
}); });
mockFetchGitRemotes.mockResolvedValue([]);
mockFetchGitRemotesDetailed.mockResolvedValue([]); mockFetchGitRemotesDetailed.mockResolvedValue([]);
mockFetchProjects.mockResolvedValue([]);
mockUseWorktrunkInstallStatus.mockReturnValue({ mockUseWorktrunkInstallStatus.mockReturnValue({
status: "missing", status: "missing",
requestInstall: vi.fn(), requestInstall: vi.fn(),
@@ -697,16 +703,23 @@ describe("SettingsModal", () => {
renderModal({ initialSection: "global-general" }); renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady(); await waitForSettingsModalReady();
const input = screen.getByLabelText("Global default tracking repo") as HTMLInputElement; const control = screen.getByRole("combobox", { name: "Global default tracking repo" }) as HTMLSelectElement;
expect(input.value).toBe(""); expect(control).toBeInTheDocument();
expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument(); expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument();
}); });
it("saves global default tracking repo via global settings payload only", async () => { it("saves global default tracking repo via global settings payload only", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchGitRemotes.mockResolvedValueOnce([{ name: "origin", owner: "octo", repo: "global-default", url: "https://github.com/octo/global-default.git" }]);
renderModal({ initialSection: "global-general" }); renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady(); await waitForSettingsModalReady();
await userEvent.type(screen.getByLabelText("Global default tracking repo"), "octo/global-default"); await waitFor(() => {
expect(screen.getByRole("combobox", { name: "Global default tracking repo" })).toHaveValue("__custom__");
});
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Global default tracking repo" }), "octo/global-default");
await userEvent.click(screen.getByRole("button", { name: "Save" })); await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
@@ -721,6 +734,16 @@ describe("SettingsModal", () => {
expect(projectPayload.githubTrackingDefaultRepo).toBeUndefined(); expect(projectPayload.githubTrackingDefaultRepo).toBeUndefined();
} }
}); });
it("shows global tracking repo error hint and keeps custom entry when lookups fail", async () => {
mockFetchProjects.mockRejectedValueOnce(new Error("no projects"));
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
expect(await screen.findByText(/Could not load project list/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText("owner/repo")).toBeInTheDocument();
});
}); });
it("renders and saves agent provisioning approval settings", async () => { it("renders and saves agent provisioning approval settings", async () => {
@@ -917,12 +940,12 @@ describe("SettingsModal", () => {
expect(screen.getByRole("heading", { name: "GitHub Tracking" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "GitHub Tracking" })).toBeInTheDocument();
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement; const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
const repoInput = screen.getByLabelText("Project default tracking repo") as HTMLInputElement; const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
expect(modeSelect.value).toBe("off"); expect(modeSelect.value).toBe("off");
expect(repoInput.value).toBe(""); expect(repoSelect.value).toBe("__custom__");
await userEvent.selectOptions(modeSelect, "new-tasks"); await userEvent.selectOptions(modeSelect, "new-tasks");
await userEvent.type(repoInput, "octo/repo"); await userEvent.type(screen.getByPlaceholderText("owner/repo"), "octo/repo");
await userEvent.click(screen.getByRole("button", { name: "Save" })); await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
@@ -950,13 +973,13 @@ describe("SettingsModal", () => {
await waitForSettingsModalReady(); await waitForSettingsModalReady();
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement; const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
const repoInput = screen.getByLabelText("Project default tracking repo") as HTMLInputElement; const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
expect(modeSelect.value).toBe("new-tasks"); expect(modeSelect.value).toBe("new-tasks");
expect(repoInput.value).toBe("octo/existing"); expect(repoSelect.value).toBe("__custom__");
await userEvent.selectOptions(modeSelect, "off"); await userEvent.selectOptions(modeSelect, "off");
await userEvent.clear(repoInput); await userEvent.clear(screen.getByPlaceholderText("owner/repo"));
await userEvent.click(screen.getByRole("button", { name: "Save" })); await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
@@ -991,6 +1014,40 @@ describe("SettingsModal", () => {
expect(screen.getByText("Title, commit message, and GitHub tracking issue summarization model")).toBeInTheDocument(); expect(screen.getByText("Title, commit message, and GitHub tracking issue summarization model")).toBeInTheDocument();
}); });
it("picks a project repo suggestion and preserves label association", async () => {
mockFetchGitRemotes.mockResolvedValueOnce([
{ name: "origin", owner: "octo", repo: "repo", url: "https://github.com/octo/repo.git" },
]);
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
await waitFor(() => {
expect(within(repoSelect).getByRole("option", { name: "octo/repo" })).toBeInTheDocument();
});
await userEvent.selectOptions(repoSelect, "octo/repo");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.githubTrackingDefaultRepo).toBe("octo/repo");
});
it("shows project tracking repo error hint and keeps custom entry when remotes fail", async () => {
mockFetchGitRemotes.mockRejectedValueOnce(new Error("remotes failed"));
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
expect(await screen.findByText(/Could not load detected remotes/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText("owner/repo")).toBeInTheDocument();
});
it("always shows GitHub tracking summarization helper copy", async () => { it("always shows GitHub tracking summarization helper copy", async () => {
renderModal({ initialSection: "general" }); renderModal({ initialSection: "general" });
await waitForSettingsModalReady(); await waitForSettingsModalReady();

View File

@@ -0,0 +1,132 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TrackingRepoSelect } from "../TrackingRepoSelect";
describe("TrackingRepoSelect", () => {
it("renders suggestions and selected option", () => {
render(
<TrackingRepoSelect
id="tracking"
value="octo/widgets"
onChange={vi.fn()}
options={[
{ value: "octo/widgets", label: "octo/widgets" },
{ value: "acme/cli", label: "acme/cli" },
]}
/>,
);
const select = screen.getByRole("combobox");
expect(select).toHaveValue("octo/widgets");
expect(screen.queryByRole("textbox")).toBeNull();
});
it("fires onChange when selecting suggestion", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<TrackingRepoSelect
id="tracking"
value=""
onChange={onChange}
options={[
{ value: "octo/widgets", label: "octo/widgets" },
{ value: "acme/cli", label: "acme/cli" },
]}
/>,
);
await user.selectOptions(screen.getByRole("combobox"), "acme/cli");
expect(onChange).toHaveBeenCalledWith("acme/cli");
});
it("shows custom input and edits custom value", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
function Harness() {
const [value, setValue] = React.useState("");
return (
<TrackingRepoSelect
id="tracking"
value={value}
onChange={(next) => {
setValue(next);
onChange(next);
}}
options={[{ value: "octo/widgets", label: "octo/widgets" }]}
/>
);
}
render(<Harness />);
await user.selectOptions(screen.getByRole("combobox"), "__custom__");
const input = screen.getByRole("textbox");
await user.type(input, "custom/repo");
expect(onChange).toHaveBeenLastCalledWith("custom/repo");
});
it("treats non-suggestion value as custom", () => {
render(
<TrackingRepoSelect
id="tracking"
value="other/repo"
onChange={vi.fn()}
options={[{ value: "octo/widgets", label: "octo/widgets" }]}
/>,
);
expect(screen.getByRole("combobox")).toHaveValue("__custom__");
expect(screen.getByRole("textbox")).toHaveValue("other/repo");
});
it("deduplicates duplicate suggestions", () => {
render(
<TrackingRepoSelect
id="tracking"
value=""
onChange={vi.fn()}
options={[
{ value: "octo/widgets", label: "octo/widgets", source: "A" },
{ value: "octo/widgets", label: "octo/widgets", source: "B" },
]}
/>,
);
const options = screen.getAllByRole("option");
expect(options.filter((option) => option.getAttribute("value") === "octo/widgets")).toHaveLength(1);
});
it("renders loading/error hints without disabling custom entry", () => {
const { rerender } = render(
<TrackingRepoSelect
id="tracking"
value=""
onChange={vi.fn()}
options={[]}
loading
/>,
);
expect(screen.getByText("Loading detected GitHub remotes…")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
rerender(
<TrackingRepoSelect
id="tracking"
value=""
onChange={vi.fn()}
options={[]}
error="Unable to load remotes"
/>,
);
expect(screen.getByText("Unable to load remotes")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
});

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts", "app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites // Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project. // stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx", "app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,TrackingRepoSelect,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior. // Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx", "app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}", "app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",