feat(dashboard): workspace detection + task prefix in import wizard

- Add POST /api/projects/detect-workspace endpoint for sub-repo scanning
- Modify POST /api/projects to accept workspaceMode + taskPrefix params
- SetupWizardModal: auto-detect sub-repos when path is entered, show
  workspace mode checkbox with detected repo count, add task prefix field
  auto-derived from project name
- Wire workspaceMode and taskPrefix through registration API call
This commit is contained in:
gsxdsm
2026-06-24 11:57:25 -07:00
parent c2030220f5
commit 9e7c85d221
3 changed files with 180 additions and 10 deletions

View File

@@ -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<ProjectInfo>
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<void> {

View File

@@ -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<WizardState> = { manualPath: path };
const updates: Partial<WizardState> = { 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({
</p>
</div>
{/*
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() && (
<div className="form-group">
<label htmlFor="workspace-mode" className="checkbox-label">
<input
id="workspace-mode"
type="checkbox"
checked={state.workspaceMode}
onChange={(e) => setState((prev) => ({ ...prev, workspaceMode: e.target.checked }))}
/>
{t("setup.workspaceMode", "Workspace mode (multi-repo)")}
</label>
{state.isDetectingWorkspace && (
<p className="form-hint">
<Loader2 size={12} className="animate-spin" style={{ display: "inline-block", verticalAlign: "middle", marginRight: 4 }} />
{t("setup.detectingWorkspace", "Detecting sub-repositories...")}
</p>
)}
{!state.isDetectingWorkspace && state.detectedRepos.length > 0 && (
<p className="form-hint">
{t("setup.detectedRepos", "Found {{count}} repositories:", { count: state.detectedRepos.length })}
{" "}
{state.detectedRepos.join(", ")}
</p>
)}
{!state.isDetectingWorkspace && state.detectedRepos.length === 0 && state.workspaceMode === false && state.manualPath.trim() && (
<p className="form-hint">
{t("setup.noSubReposDetected", "No sub-repositories detected. Enable if this is a multi-repo workspace.")}
</p>
)}
</div>
)}
{/*
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.
*/}
<div className="form-group">
<label htmlFor="task-prefix">{t("setup.taskPrefix", "Task Prefix")}</label>
<input
id="task-prefix"
type="text"
value={state.manualTaskPrefix}
onChange={(e) => setState((prev) => ({ ...prev, manualTaskPrefix: e.target.value.toUpperCase() }))}
placeholder={suggestTaskPrefixFromName(state.manualName || "FN")}
maxLength={5}
/>
<p className="form-hint">
{t("setup.taskPrefixHint", "Used for task IDs (e.g. \"{{prefix}}-1\"). Derived from project name.", { prefix: state.manualTaskPrefix || "FN" })}
</p>
</div>
<div className="setup-wizard-advanced">
<button
type="button"

View File

@@ -210,6 +210,39 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/**
* POST /api/projects/detect-workspace
* Probe a directory for git sub-repos (workspace mode detection).
* Body: { path: string }
* Returns: { repos: string[], isWorkspace: boolean }
*/
router.post("/projects/detect-workspace", async (req, res) => {
try {
const { path } = req.body;
if (!path || typeof path !== "string" || !path.trim()) {
throw badRequest("path is required");
}
const normalizedPath = path.trim();
if (!isAbsolute(normalizedPath)) {
throw badRequest("path must be an absolute path");
}
try {
await access(normalizedPath);
} catch {
throw badRequest("Project path does not exist");
}
const { detectWorkspaceRepos } = await import("@fusion/core");
const repos = await detectWorkspaceRepos(normalizedPath);
res.json({ repos, isWorkspace: repos.length > 0 });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
throw new ApiError(500, err instanceof Error ? err.message : String(err));
}
});
/**
* POST /api/projects
* Register a new project.
@@ -218,13 +251,15 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
* path: string,
* isolationMode?: "in-process" | "child-process",
* nodeId?: string,
* cloneUrl?: string
* cloneUrl?: string,
* workspaceMode?: boolean,
* taskPrefix?: string
* }
* Returns: RegisteredProject
*/
router.post("/projects", async (req, res) => {
try {
const { name, path, isolationMode = "in-process", nodeId, cloneUrl } = req.body;
const { name, path, isolationMode = "in-process", nodeId, cloneUrl, workspaceMode, taskPrefix } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
throw badRequest("name is required and must be a non-empty string");
@@ -376,16 +411,39 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
/*
FNXC:Onboarding 2026-06-24-18:00:
For new registrations (not reattachments), set a derived task prefix and default
workflow via the per-project TaskStore config.json so the project is immediately
usable without manual settings configuration.
For new registrations (not reattachments), configure workspace mode (if specified or
auto-detected), set a task prefix, and default workflow via the per-project TaskStore
config.json so the project is immediately usable without manual settings configuration.
*/
if (activeProjectWithOutcome.outcome === "registered") {
try {
const { TaskStore, suggestTaskPrefix } = await import("@fusion/core");
const { TaskStore, suggestTaskPrefix, detectWorkspaceRepos, saveWorkspaceConfig } = await import("@fusion/core");
const store = new TaskStore(normalizedPath);
await store.init();
const prefix = suggestTaskPrefix(normalizedName);
/*
FNXC:Workspace 2026-06-24-19:00:
Workspace mode: if the client explicitly requested it (workspaceMode: true from the
wizard checkbox), detect and persist sub-repos. If the client didn't specify and
auto-detection finds sub-repos, also apply it. This mirrors the CLI interactive flow.
*/
if (workspaceMode === true) {
const repos = await detectWorkspaceRepos(normalizedPath);
if (repos.length > 0) {
await saveWorkspaceConfig(normalizedPath, { repos });
}
await store.updateSettings({ workspaceMode: true });
} else if (workspaceMode === undefined) {
const repos = await detectWorkspaceRepos(normalizedPath);
if (repos.length > 0) {
await saveWorkspaceConfig(normalizedPath, { repos });
await store.updateSettings({ workspaceMode: true });
}
}
const prefix = typeof taskPrefix === "string" && taskPrefix.trim()
? taskPrefix.trim().toUpperCase()
: suggestTaskPrefix(normalizedName);
await store.updateSettings({
taskPrefix: prefix,
defaultWorkflowId: "builtin:coding",