diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b4dfa9d33a..876bede201 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6770,6 +6770,8 @@ export interface ProjectCreateInput { isolationMode?: "in-process" | "child-process"; nodeId?: string; cloneUrl?: string; + workspaceMode?: boolean; + taskPrefix?: string; } export type DockerNodeConfigInfo = DockerNodeConfig; @@ -7236,6 +7238,13 @@ export function registerProject(input: ProjectCreateInput): Promise body: JSON.stringify(input), }); } +/** Detect git sub-repos in a directory (workspace mode detection) */ +export function detectWorkspace(path: string): Promise<{ repos: string[]; isWorkspace: boolean }> { + return api<{ repos: string[]; isWorkspace: boolean }>("/projects/detect-workspace", { + method: "POST", + body: JSON.stringify({ path }), + }); +} /** Unregister a project */ export function unregisterProject(id: string): Promise { diff --git a/packages/dashboard/app/components/SetupWizardModal.tsx b/packages/dashboard/app/components/SetupWizardModal.tsx index 324da042dc..32323b54c2 100644 --- a/packages/dashboard/app/components/SetupWizardModal.tsx +++ b/packages/dashboard/app/components/SetupWizardModal.tsx @@ -3,9 +3,22 @@ import { lazy, Suspense, useState, useCallback, useMemo, useRef, useEffect, type import { X, Loader2, CheckCircle, ChevronRight, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AgentOnboardingSummary, ProjectInfo, ProjectCreateInput } from "../api"; -import { createAgent, registerProject } from "../api"; +import { createAgent, registerProject, detectWorkspace } from "../api"; import { DirectoryPicker } from "./DirectoryPicker"; import { suggestProjectName } from "../utils/projectDetection"; + +/* +FNXC:TaskPrefix 2026-06-24-19:00: +Derive a task prefix from a project name in the browser. Mirrors the logic in +@fusion/core's suggestTaskPrefix: strip non-alpha, uppercase, take 2-4 chars, +fall back to "FN". Duplicated because @fusion/core is server-only. +*/ +function suggestTaskPrefixFromName(name: string): string { + const cleaned = name.replace(/[^a-zA-Z]/g, "").toUpperCase(); + if (cleaned.length >= 2 && cleaned.length <= 4) return cleaned; + if (cleaned.length > 4) return cleaned.slice(0, 4); + return "FN"; +} import { useNodes } from "../hooks/useNodes"; import { AgentAvatar } from "./AgentAvatar"; import { ErrorBoundary } from "./ErrorBoundary"; @@ -44,6 +57,10 @@ interface WizardState { manualName: string; manualIsolationMode: "in-process" | "child-process"; manualNodeId: string; + manualTaskPrefix: string; + detectedRepos: string[]; + workspaceMode: boolean; + isDetectingWorkspace: boolean; registeredProject: ProjectInfo | null; selectedPresetId: string; agentDraft: AgentDraftValues; @@ -90,6 +107,10 @@ export function SetupWizardModal({ manualName: "", manualIsolationMode: "in-process", manualNodeId: "", + manualTaskPrefix: "", + detectedRepos: [], + workspaceMode: false, + isDetectingWorkspace: false, registeredProject: null, selectedPresetId: ceoPreset.id, agentDraft: mapPresetToAgentDraft(ceoPreset), @@ -127,13 +148,35 @@ export function SetupWizardModal({ const handlePathChange = useCallback((path: string) => { setState((prev) => { - const updates: Partial = { manualPath: path }; + const updates: Partial = { manualPath: path, detectedRepos: [], workspaceMode: false }; // Auto-suggest name when path changes and name is empty or was previously auto-suggested if (path && (!prev.manualName || prev.manualName === suggestProjectName(prev.manualPath))) { updates.manualName = suggestProjectName(path); } + // Auto-suggest prefix when name changes and prefix is empty or was previously auto-suggested + const suggestedName = updates.manualName ?? prev.manualName; + if (suggestedName && (!prev.manualTaskPrefix || prev.manualTaskPrefix === suggestTaskPrefixFromName(suggestProjectName(prev.manualPath)))) { + updates.manualTaskPrefix = suggestTaskPrefixFromName(suggestedName); + } return { ...prev, ...updates }; }); + + // Detect workspace sub-repos when path is set (existing directory mode only) + if (path.trim() && path.trim() !== "/") { + setState((prev) => ({ ...prev, isDetectingWorkspace: true })); + detectWorkspace(path.trim()) + .then((result) => { + setState((prev) => ({ + ...prev, + isDetectingWorkspace: false, + detectedRepos: result.repos, + workspaceMode: result.isWorkspace, + })); + }) + .catch(() => { + setState((prev) => ({ ...prev, isDetectingWorkspace: false })); + }); + } }, []); const handleManualRegister = useCallback(async () => { @@ -153,6 +196,8 @@ export function SetupWizardModal({ isolationMode: state.manualIsolationMode, nodeId: state.manualNodeId || undefined, cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined, + workspaceMode: state.workspaceMode || undefined, + taskPrefix: state.manualTaskPrefix.trim() || undefined, }; const result = await registerProject(input); @@ -179,7 +224,7 @@ export function SetupWizardModal({ error: err instanceof Error ? err.message : "Failed to register project", })); } - }, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId]); + }, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, state.workspaceMode, state.manualTaskPrefix]); const handlePresetSelect = useCallback((presetId: string) => { const preset = getPresetById(presetId); @@ -369,6 +414,64 @@ export function SetupWizardModal({

+ {/* + FNXC:Workspace 2026-06-24-19:00: + Workspace mode detection: when the selected directory contains git sub-repos, + show a checkbox letting the user opt into workspace mode. In workspace mode, + tasks run per-sub-repo and no git repo is created at the root. + */} + {isExistingMode && state.manualPath.trim() && ( +
+ + {state.isDetectingWorkspace && ( +

+ + {t("setup.detectingWorkspace", "Detecting sub-repositories...")} +

+ )} + {!state.isDetectingWorkspace && state.detectedRepos.length > 0 && ( +

+ {t("setup.detectedRepos", "Found {{count}} repositories:", { count: state.detectedRepos.length })} + {" "} + {state.detectedRepos.join(", ")} +

+ )} + {!state.isDetectingWorkspace && state.detectedRepos.length === 0 && state.workspaceMode === false && state.manualPath.trim() && ( +

+ {t("setup.noSubReposDetected", "No sub-repositories detected. Enable if this is a multi-repo workspace.")} +

+ )} +
+ )} + + {/* + FNXC:TaskPrefix 2026-06-24-19:00: + Task prefix field: auto-derived from the project name. The prefix is used + for task IDs (e.g. "MYPR-1"). Users can override it. + */} +
+ + setState((prev) => ({ ...prev, manualTaskPrefix: e.target.value.toUpperCase() }))} + placeholder={suggestTaskPrefixFromName(state.manualName || "FN")} + maxLength={5} + /> +

+ {t("setup.taskPrefixHint", "Used for task IDs (e.g. \"{{prefix}}-1\"). Derived from project name.", { prefix: state.manualTaskPrefix || "FN" })} +

+
+