diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 044a718867..cd171d135a 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -680,14 +680,17 @@ export async function registerProjectInteractive( // Initialize the project (create .fusion/) const { TaskStore } = await import("@fusion/core"); const store = new TaskStore(absPath); - await store.init(); - if (detectedSubRepos) { - await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); - // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard - await store.updateSettings({ workspaceMode: true }); + try { + await store.init(); + if (detectedSubRepos) { + await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); + // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard + await store.updateSettings({ workspaceMode: true }); + } + console.log(` ✓ Initialized fn at ${absPath}`); + } finally { + await store.close(); } - await store.close(); - console.log(` ✓ Initialized fn at ${absPath}`); } else { throw new ProjectResolutionError( "Cannot register project without .fusion/ directory. Run `fn init` first.", @@ -747,29 +750,32 @@ export async function registerProjectInteractive( /* FNXC:Onboarding 2026-06-24-18:00: - After registration, prompt the user to confirm a task prefix and default workflow. - The prefix defaults to the first 2-4 chars of the project name so each project gets - recognizable task IDs (e.g., "MYPR" for "my-project"). The workflow defaults to coding. - Both are persisted to config.json via the TaskStore. + After registration, set a task prefix and default workflow. The prefix defaults to + the first 2-4 chars of the project name so each project gets recognizable task IDs + (e.g., "MYPR" for "my-project"). The workflow defaults to coding. Both are persisted + to config.json via the TaskStore. */ - if (interactive) { + { const { TaskStore } = await import("@fusion/core"); const store = new TaskStore(absPath); - await store.init(); - - const suggestedPrefix = suggestTaskPrefix(name); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const prefixInput = await rl.question(`\n Task prefix [${suggestedPrefix}]: `); - rl.close(); - const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, ""); - const prefix = rawPrefix.length >= 2 && rawPrefix.length <= 5 ? rawPrefix : suggestedPrefix; - - await store.updateSettings({ - taskPrefix: prefix, - defaultWorkflowId: "builtin:coding", - }); - await store.close(); - console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`); + try { + await store.init(); + let prefix = suggestTaskPrefix(name); + if (interactive) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const prefixInput = await rl.question(`\n Task prefix [${prefix}]: `); + rl.close(); + const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, ""); + if (rawPrefix.length >= 1 && rawPrefix.length <= 5) prefix = rawPrefix; + } + await store.updateSettings({ + taskPrefix: prefix, + defaultWorkflowId: "builtin:coding", + }); + console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`); + } finally { + await store.close(); + } } return createResolvedProject(project); diff --git a/packages/core/src/distributed-task-id.ts b/packages/core/src/distributed-task-id.ts index 04ba930b00..98a5aed311 100644 --- a/packages/core/src/distributed-task-id.ts +++ b/packages/core/src/distributed-task-id.ts @@ -77,16 +77,16 @@ function getConfiguredPrefixAndLegacyNextId(db: Database): { prefix: string; nex .prepare("SELECT nextId, settings FROM config WHERE id = 1") .get() as { nextId: number | null; settings: string | null } | undefined; if (!row) { - return { prefix: "KB", nextId: null }; + return { prefix: "FN", nextId: null }; } const settings = row.settings ? (JSON.parse(row.settings) as { taskPrefix?: string }) : null; return { - prefix: (settings?.taskPrefix ?? "KB").trim().toUpperCase(), + prefix: (settings?.taskPrefix ?? "FN").trim().toUpperCase(), nextId: typeof row.nextId === "number" ? row.nextId : null, }; } catch { - return { prefix: "KB", nextId: null }; + return { prefix: "FN", nextId: null }; } } 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..89461dad2b 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), @@ -125,16 +146,48 @@ export function SetupWizardModal({ } }, [state.agentError]); + const detectWorkspaceRequestId = useRef(0); + 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 }; }); - }, []); + + /* + FNXC:Workspace 2026-06-24-21:00: + Detect workspace sub-repos only in existing-directory mode (clone mode creates a fresh + directory with a single repo). A monotonic request ID guards against stale responses + overwriting state from a newer path entry (race condition on rapid typing). + */ + if (state.manualMode === "existing" && path.trim() && path.trim() !== "/") { + const requestId = ++detectWorkspaceRequestId.current; + setState((prev) => ({ ...prev, isDetectingWorkspace: true })); + detectWorkspace(path.trim()) + .then((result) => { + if (requestId !== detectWorkspaceRequestId.current) return; + setState((prev) => ({ + ...prev, + isDetectingWorkspace: false, + detectedRepos: result.repos, + workspaceMode: result.isWorkspace, + })); + }) + .catch(() => { + if (requestId !== detectWorkspaceRequestId.current) return; + setState((prev) => ({ ...prev, isDetectingWorkspace: false })); + }); + } + }, [state.manualMode]); const handleManualRegister = useCallback(async () => { const trimmedPath = state.manualPath.trim(); @@ -153,6 +206,8 @@ export function SetupWizardModal({ isolationMode: state.manualIsolationMode, nodeId: state.manualNodeId || undefined, cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined, + workspaceMode: state.workspaceMode, + taskPrefix: state.manualTaskPrefix.trim() || undefined, }; const result = await registerProject(input); @@ -179,7 +234,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 +424,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" })} +

+
+