diff --git a/docs/public-readiness-plan.md b/docs/public-readiness-plan.md index a8afa0b..5da41d2 100644 --- a/docs/public-readiness-plan.md +++ b/docs/public-readiness-plan.md @@ -20,6 +20,7 @@ Setlist is moving from a personal registry toward a local, agent-readable worksp - Done: core-owned local workspace inspection now enriches agent briefs with lightweight code and non-code source signals without broad indexing or external calls. - Done: richer source adapter paths are now being developed in parallel for code and non-code workspaces, adding bounded manifest/dependency/runtime signals and document-category/folder-role signals without persisting inspection-derived fields. - Done: Electron first-run onboarding now guides a user through registering one existing local folder and previewing shallow local inspection signals before registration. +- Done: existing-folder onboarding now lets users choose from live, user-managed project types while keeping broad code/workspace defaults as fallback. ## Slice Proof: Alpha MCP Client Controls Tester Packet @@ -57,16 +58,18 @@ What remains: ## Slice Proof: Guided Existing-Folder Registration - `@setlist/core` now owns `Registry.inspectWorkspaceCandidate(...)` and `Registry.registerExistingWorkspace(...)`, so the app does not decide registration or inspection policy in renderer code. -- The app main process exposes narrow IPC calls for candidate inspection and existing-workspace registration. The renderer only collects name, display name, broad workspace kind, local path, optional structure fields, and shows returned results. +- The app main process exposes narrow IPC calls for candidate inspection and existing-workspace registration. The renderer only collects name, display name, selected project type when available, broad workspace kind fallback, local path, optional structure fields, and shows returned results. - The empty first-run state now leads with "Register existing folder" and keeps "Create new workspace" as a secondary path. This keeps non-code folders and already-existing workspaces first-class during onboarding. - Existing-folder registration shows explicit local-first copy: Setlist reads shallow local metadata on the Mac, does not upload contents, does not call an LLM, and does not generate an automatic digest. +- Existing-folder registration now accepts a selected live `project_type_id` from the user-managed `project_types` table. Core validates the selected type and still falls back to broad code/workspace seeded defaults when no specific type is selected. +- Custom non-code project types remain first-class: registration does not require `tech_stack`, does not persist fake code metadata, and brief/enrichment gaps stay workspace-appropriate. - The preview displays local inspection status, code signals when present, document/folder signals when present, notable files, material hints, and structured gaps such as empty, missing, inaccessible, or entry-limit conditions. - Registration lands the user on the newly registered project overview, where the existing Agent Brief and Paths sections provide a useful next surface without broad indexing or background digest work. What remains: - Add richer folder-picker affordances later if manual path entry proves awkward in the packaged app. -- Decide whether existing-folder registration should eventually support custom project types directly, beyond the current broad code/workspace mapping to seeded defaults. +- Watch real onboarding sessions for confusing project-type labels or missing default types before adding more UI. ## Slice Proof: Workspace Source Inspection @@ -229,7 +232,7 @@ Awareness should point to proof, not vibes: before/after agent orientation, Clau ## Next Implementation Slices 1. Run the app-side MCP client controls tester packet/checklist with real alpha users and make only targeted copy refinements where users get stuck. -2. Expand the compatibility matrix only when another client has real detection, planning, install, backup, and recovery behavior in code. -3. Use the launch-awareness drafts only as lightweight feedback copy until real alpha MCP-control feedback and a boringly repeatable install/onboarding path support broader publication. -4. Turn the single-page marketing-site outline into an actual page only after README proof, compatibility docs, and install/onboarding guidance stay in sync. -5. Refine existing-folder onboarding with custom project-type selection only after the broad code/workspace prototype has been used. +2. Watch the custom project-type onboarding path with alpha users and tune labels/copy only where real confusion appears. +3. Expand the compatibility matrix only when another client has real detection, planning, install, backup, and recovery behavior in code. +4. Use the launch-awareness drafts only as lightweight feedback copy until real alpha MCP-control feedback and a boringly repeatable install/onboarding path support broader publication. +5. Turn the single-page marketing-site outline into an actual page only after README proof, compatibility docs, and install/onboarding guidance stay in sync. diff --git a/packages/app/e2e/app.spec.ts b/packages/app/e2e/app.spec.ts index 034644f..7a75ffa 100644 --- a/packages/app/e2e/app.spec.ts +++ b/packages/app/e2e/app.spec.ts @@ -150,13 +150,19 @@ args = ["-y", "@setlist/mcp"] mkdirSync(join(workspace, 'Research Notes'), { recursive: true }) writeFileSync(join(workspace, 'client-plan.md'), '# Plan\n') writeFileSync(join(workspace, 'Research Notes', 'source-notes.txt'), 'Sources\n') + await window.evaluate(() => (window as any).setlist.createProjectType({ + name: 'Research workspace', + default_directory: '/tmp', + git_init: false, + color: '#10b981' + })) await window.getByRole('button', { name: 'Register existing folder' }).first().click() await expect(window.getByRole('heading', { name: 'Register existing folder' })).toBeVisible() await expect(window.getByText(/shallow local metadata/)).toBeVisible() const typePicker = window.getByLabel('Type') - await typePicker.selectOption('non_code_project') - await expect(typePicker).toHaveValue('non_code_project') + await expect(typePicker.locator('option', { hasText: 'Research workspace' })).toHaveCount(1) + await typePicker.selectOption({ label: 'Research workspace' }) await window.getByLabel('Name (slug)').fill('client-research') await window.getByLabel('Display name (optional)').fill('Client Research') await window.getByLabel('Local folder path').fill(workspace) @@ -167,6 +173,9 @@ args = ["-y", "@setlist/mcp"] await window.getByRole('button', { name: 'Register' }).click() await expect(window.getByRole('heading', { name: 'Client Research' })).toBeVisible() await expect(window.getByText(workspace)).toBeVisible() + const project = await window.evaluate(() => (window as any).setlist.getProject('client-research', 'standard')) + expect(project.project_type).toBe('Research workspace') + expect(project.project_type_id).toBeTruthy() } finally { await app.close() rmSync(home, { recursive: true, force: true }) diff --git a/packages/app/out/main/index.js b/packages/app/out/main/index.js index f7b3687..fb865ed 100644 --- a/packages/app/out/main/index.js +++ b/packages/app/out/main/index.js @@ -2655,6 +2655,17 @@ class Registry { throw new InvalidInputError(`Workspace path is not a readable directory: ${inspectedPath?.error ?? normalizedPath}`); } const displayName = opts.display_name?.trim() || void 0; + const typeDb = this.open(); + let projectTypeId; + try { + projectTypeId = this.resolveExistingWorkspaceProjectTypeId(typeDb, { + project_type_id: opts.project_type_id, + workspace_kind: opts.workspace_kind, + inspection + }); + } finally { + typeDb.close(); + } this.register({ name: projectName, type: "project", @@ -2671,10 +2682,8 @@ class Registry { }); const db = this.open(); try { - const typeName = opts.workspace_kind === "code" ? "Code project" : "Non-code project"; - const typeRow = db.prepare("SELECT id FROM project_types WHERE name = ?").get(typeName); - if (typeRow) { - db.prepare("UPDATE projects SET project_type_id = ?, updated_at = datetime('now') WHERE name = ?").run(typeRow.id, projectName); + if (projectTypeId != null) { + db.prepare("UPDATE projects SET project_type_id = ?, updated_at = datetime('now') WHERE name = ?").run(projectTypeId, projectName); } } finally { db.close(); @@ -2684,6 +2693,21 @@ class Registry { inspection }; } + resolveExistingWorkspaceProjectTypeId(db, opts) { + if (opts.project_type_id != null) { + if (!Number.isInteger(opts.project_type_id) || opts.project_type_id <= 0) { + throw new InvalidProjectTypeError(opts.project_type_id); + } + const row2 = db.prepare("SELECT id FROM project_types WHERE id = ?").get(opts.project_type_id); + if (!row2) + throw new InvalidProjectTypeError(opts.project_type_id); + return row2.id; + } + const fallbackKind = opts.workspace_kind ?? (opts.inspection.summary.kind === "code" ? "code" : "workspace"); + const typeName = fallbackKind === "code" ? "Code project" : "Non-code project"; + const row = db.prepare("SELECT id FROM project_types WHERE name = ?").get(typeName); + return row?.id ?? null; + } listProjects(opts) { const depth = opts?.depth ?? "summary"; const db = this.open(); diff --git a/packages/app/out/renderer/assets/index-Siu6r3F2.js b/packages/app/out/renderer/assets/index-9KfjZFFz.js similarity index 99% rename from packages/app/out/renderer/assets/index-Siu6r3F2.js rename to packages/app/out/renderer/assets/index-9KfjZFFz.js index b7bc176..98579b6 100644 --- a/packages/app/out/renderer/assets/index-Siu6r3F2.js +++ b/packages/app/out/renderer/assets/index-9KfjZFFz.js @@ -22170,7 +22170,7 @@ function SettingsView({ onBack }) { /* @__PURE__ */ jsxRuntimeExports.jsx(UpdatesSection, {}) ] }); } -const PROJECT_TYPES = [ +const BROAD_PROJECT_TYPES = [ { value: "code_project", label: "Code repository or app", workspaceKind: "code" }, { value: "non_code_project", label: "General workspace or folder", workspaceKind: "workspace" } ]; @@ -22185,10 +22185,18 @@ function slugFromText(value) { function folderName(path) { return path.split("/").filter(Boolean).pop() ?? ""; } +function defaultProjectTypeFor(types, fallback) { + const wantsCode = fallback === "code_project"; + const seededName = wantsCode ? "Code project" : "Non-code project"; + return types.find((type) => type.name === seededName) ?? types.find((type) => type.git_init === wantsCode); +} function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onSuccess }) { const [name, setName] = reactExports.useState(""); const [displayName, setDisplayName] = reactExports.useState(""); - const [type, setType] = reactExports.useState("code_project"); + const [fallbackType, setFallbackType] = reactExports.useState("code_project"); + const [projectTypes, setProjectTypes] = reactExports.useState([]); + const [projectTypesLoading, setProjectTypesLoading] = reactExports.useState(false); + const [selectedProjectTypeId, setSelectedProjectTypeId] = reactExports.useState(null); const [description, setDescription] = reactExports.useState(""); const [area, setArea] = reactExports.useState(UNASSIGNED); const [parentProject, setParentProject] = reactExports.useState(""); @@ -22207,15 +22215,22 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS const [inspecting, setInspecting] = reactExports.useState(false); reactExports.useEffect(() => { if (open) { + const defaultFallback = initialMode === "existing" ? "non_code_project" : "code_project"; setCreateFolder(initialMode !== "existing"); - if (initialMode === "existing") setType("non_code_project"); + setFallbackType(defaultFallback); + setSelectedProjectTypeId(null); api.getBootstrapConfig().then(setConfig).catch(() => setConfig(null)); + setProjectTypesLoading(true); + api.listProjectTypes().then((types) => { + setProjectTypes(types); + setSelectedProjectTypeId(defaultProjectTypeFor(types, defaultFallback)?.id ?? null); + }).catch(() => setProjectTypes([])).finally(() => setProjectTypesLoading(false)); api.listProjects({ depth: "summary" }).then((projects) => setParentOptions(projects)).catch(() => setParentOptions([])); } }, [open, initialMode]); reactExports.useEffect(() => { setInspection(null); - }, [existingFolder, type]); + }, [existingFolder, fallbackType, selectedProjectTypeId]); reactExports.useEffect(() => { const trimmed = name.trim(); setNameWarning(null); @@ -22232,12 +22247,26 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS }, 300); return () => clearTimeout(checkTimer.current); }, [name]); - const pathKey = TYPE_TO_PATH_KEY[type]; - const pathRoot = config?.path_roots?.[pathKey]; - const hasBootstrapConfig = config && Object.keys(config.path_roots).length > 0; + const selectedProjectType = selectedProjectTypeId == null ? null : projectTypes.find((type) => type.id === selectedProjectTypeId) ?? null; + const selectedTypeValue = selectedProjectType ? `project-type:${selectedProjectType.id}` : fallbackType; + const selectedBroadType = selectedProjectType ? selectedProjectType.git_init ? "code_project" : "non_code_project" : fallbackType; + const pathKey = TYPE_TO_PATH_KEY[selectedBroadType]; + const pathRoot = selectedProjectType?.default_directory ?? config?.path_roots?.[pathKey]; + const hasBootstrapConfig = Boolean(selectedProjectType) || Boolean(config && Object.keys(config.path_roots).length > 0); const resolvedPath = pathRoot && name.trim() ? `${pathRoot}/${name.trim()}` : null; - const workspaceKind = PROJECT_TYPES.find((candidate) => candidate.value === type)?.workspaceKind; + const workspaceKind = selectedProjectType ? selectedProjectType.git_init ? "code" : "workspace" : BROAD_PROJECT_TYPES.find((candidate) => candidate.value === fallbackType)?.workspaceKind; const selectedFolder = existingFolder?.trim() ?? ""; + const handleTypeChange = (value) => { + if (value.startsWith("project-type:")) { + const id = Number(value.slice("project-type:".length)); + const projectType = projectTypes.find((type) => type.id === id); + setSelectedProjectTypeId(projectType?.id ?? null); + if (projectType) setFallbackType(projectType.git_init ? "code_project" : "non_code_project"); + return; + } + setSelectedProjectTypeId(null); + setFallbackType(value); + }; const setExistingFolderValue = (value) => { setExistingFolder(value); if (!name.trim()) { @@ -22286,11 +22315,12 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS if (createFolder) { await api.bootstrapProject({ name: name.trim(), - type: type === "code_project" ? "project" : "non_code_project", + type: selectedBroadType === "code_project" ? "project" : "non_code_project", + project_type_id: selectedProjectType?.id, status: "active", description: description.trim() || void 0, display_name: displayName.trim() || void 0, - skip_git: skipGit || type !== "code_project", + skip_git: skipGit || selectedBroadType !== "code_project", area: resolvedArea, parent_project: resolvedParent, email_account: resolvedEmail @@ -22304,6 +22334,7 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS name: name.trim(), path: selectedFolder, workspace_kind: workspaceKind, + project_type_id: selectedProjectType?.id, status: "active", description: description.trim() || void 0, display_name: displayName.trim() || void 0, @@ -22316,7 +22347,8 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS const registeredName = name.trim(); setName(""); setDisplayName(""); - setType("code_project"); + setFallbackType("code_project"); + setSelectedProjectTypeId(null); setDescription(""); setArea(UNASSIGNED); setParentProject(""); @@ -22364,15 +22396,18 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS className: "input-field" } ) }), - /* @__PURE__ */ jsxRuntimeExports.jsx(Field, { label: "Type", children: /* @__PURE__ */ jsxRuntimeExports.jsx( - "select", - { - value: type, - onChange: (e) => setType(e.target.value), - className: "input-field", - children: PROJECT_TYPES.map((t) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: t.value, children: t.label }, t.value)) - } - ) }), + /* @__PURE__ */ jsxRuntimeExports.jsxs(Field, { label: "Type", children: [ + /* @__PURE__ */ jsxRuntimeExports.jsx( + "select", + { + value: selectedTypeValue, + onChange: (e) => handleTypeChange(e.target.value), + className: "input-field", + children: projectTypes.length > 0 ? projectTypes.map((t) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: `project-type:${t.id}`, children: t.name }, t.id)) : BROAD_PROJECT_TYPES.map((t) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: t.value, children: t.label }, t.value)) + } + ), + projectTypesLoading && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "mt-1 text-xs text-[var(--color-text-tertiary)]", children: "Loading saved project types..." }) + ] }), /* @__PURE__ */ jsxRuntimeExports.jsx(Field, { label: "Description (optional)", children: /* @__PURE__ */ jsxRuntimeExports.jsx( "textarea", { @@ -22448,7 +22483,7 @@ function RegisterProjectDialog({ open, onOpenChange, initialMode = "create", onS "Templates from ", config.template_dir ] }), - type === "code_project" && /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex items-center gap-2 cursor-pointer", children: [ + selectedBroadType === "code_project" && /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex items-center gap-2 cursor-pointer", children: [ /* @__PURE__ */ jsxRuntimeExports.jsx( "input", { diff --git a/packages/app/out/renderer/index.html b/packages/app/out/renderer/index.html index c1bb81e..41fa975 100644 --- a/packages/app/out/renderer/index.html +++ b/packages/app/out/renderer/index.html @@ -4,7 +4,7 @@