diff --git a/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx b/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx index 83f7d63823..99d25d8169 100644 --- a/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx +++ b/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx @@ -9,7 +9,7 @@ import { NoticePanel } from "./SettingsSurface.js"; import { SHARED_INTERACTION_CLASSES } from "../ui/Button.js"; import { useInteractionTokens } from "../../lib/motion/tokens.js"; -import { AlertTriangle, Bot, BrainCircuit, ChevronDown, Compass, Cpu, Monitor, Plug, Server, Settings, SlidersHorizontal, Target } from "lucide-preact"; +import { AlertTriangle, Bot, BrainCircuit, ChevronDown, Compass, Cpu, Layers3, Monitor, Plug, Server, Settings, SlidersHorizontal, Target } from "lucide-preact"; export const CATEGORIES: Category[] = [ { id: "general", num: "01", label: "General", icon: SlidersHorizontal, description: "Scope, runtime, and automation posture" }, @@ -17,11 +17,12 @@ export const CATEGORIES: Category[] = [ { id: "models", num: "03", label: "AI Models", icon: Cpu, description: "Provider routing, models, and weighting" }, { id: "sprint", num: "04", label: "Sprint & Git", icon: Target, description: "Git flow, branch naming, merge rules, and execution runtime" }, { id: "browser", num: "05", label: "Browser Preview", icon: Compass, description: "Preview runtime, browser visibility, and container policy" }, - { id: "agents", num: "06", label: "Agents", icon: Bot, description: "Agent routing, skill storage, reflection, and authoring behavior" }, - { id: "memory", num: "07", label: "Memory", icon: BrainCircuit, description: "Embedding models, auto-capture, and promotion policy" }, - { id: "integrations", num: "08", label: "Integrations", icon: Plug, description: "Provider keys, Git hosts, and external connection policy" }, - { id: "mcp", num: "09", label: "MCP", icon: Server, description: "MCP servers injected into CLIs and built-in tool access" }, - { id: "danger", num: "10", label: "Danger Zone", icon: AlertTriangle, description: "Reset project overrides only when needed", danger: true }, + { id: "techstacks", num: "06", label: "Techstacks", icon: Layers3, description: "Catalog stacks, application kind, and project assignment" }, + { id: "agents", num: "07", label: "Agents", icon: Bot, description: "Agent routing, skill storage, reflection, and authoring behavior" }, + { id: "memory", num: "08", label: "Memory", icon: BrainCircuit, description: "Embedding models, auto-capture, and promotion policy" }, + { id: "integrations", num: "09", label: "Integrations", icon: Plug, description: "Provider keys, Git hosts, and external connection policy" }, + { id: "mcp", num: "10", label: "MCP", icon: Server, description: "MCP servers injected into CLIs and built-in tool access" }, + { id: "danger", num: "11", label: "Danger Zone", icon: AlertTriangle, description: "Reset project overrides only when needed", danger: true }, ]; export interface SettingsCategoryRailProps { diff --git a/dashboard/src/v2/components/settings/SettingsContentPanels.tsx b/dashboard/src/v2/components/settings/SettingsContentPanels.tsx index 549db6eafe..f2ea3dc0bc 100644 --- a/dashboard/src/v2/components/settings/SettingsContentPanels.tsx +++ b/dashboard/src/v2/components/settings/SettingsContentPanels.tsx @@ -5,6 +5,7 @@ import { SettingsAppearancePanel } from "./panels/SettingsAppearancePanel.js"; import { SettingsModelsPanel } from "./panels/SettingsModelsPanel.js"; import { SettingsSprintPanel } from "./panels/SettingsSprintPanel.js"; import { SettingsBrowserPanel } from "./panels/SettingsBrowserPanel.js"; +import { SettingsTechstacksPanel } from "./panels/SettingsTechstacksPanel.js"; import { SettingsAgentsPanel } from "./panels/SettingsAgentsPanel.js"; import { SettingsMemoryPanel } from "./panels/SettingsMemoryPanel.js"; import { SettingsIntegrationsPanel } from "./panels/SettingsIntegrationsPanel.js"; @@ -61,6 +62,8 @@ export const SettingsContentPanels: FunctionComponent<{ return ; case "browser": return ; + case "techstacks": + return ; case "agents": return ; case "memory": diff --git a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx index 4984a8c334..09a7c07c06 100644 --- a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx +++ b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { h } from "preact"; +import { useState } from "preact/hooks"; import { readFileSync } from "node:fs"; import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/preact"; @@ -11,15 +12,19 @@ import { SprintKeyEditor } from "../SprintKeyEditor"; import { TextInput, SecretInput, NumberInput, TextAreaInput, PillChoiceGroup, SelectInput } from "../SettingsFormFields"; -import { SettingsCategoryRail } from "../SettingsCategoryRail"; +import { CATEGORIES, SettingsCategoryRail } from "../SettingsCategoryRail"; import { ActionButton, NoticePanel } from "../SettingsSurface"; import { OverrideBadge } from "../panels/SharedPanelComponents"; +import { SettingsTechstacksPanel } from "../panels/SettingsTechstacksPanel"; import { SlidersHorizontal } from "lucide-preact"; import type { SettingsSearchMatches } from "../../../lib/settings-search-index"; import userEvent from "@testing-library/user-event"; import { SettingsContentPanels } from "../SettingsContentPanels"; import { UnsavedChangesModal } from "../../ui/UnsavedChangesModal"; import { ProviderInstanceCard } from "../ProviderInstanceCard"; +import { DEFAULT_DASHBOARD_SETTINGS } from "../../../../lib/settings"; +import { dashboardSettingsToProjectSettings } from "../../../lib/settings-view-models"; +import type { ProjectSettings, SystemSettings, TechstackCatalogEntrySettings } from "../../../../types"; const defaultInnerHeight = window.innerHeight; @@ -33,6 +38,44 @@ vi.mock("../panels/SettingsGeneralPanel", () => ({ SettingsGeneralPanel: () =>
General panel values stay mounted
, })); +const customTechstack: TechstackCatalogEntrySettings = { + id: "custom-web", + label: "Custom Web", + items: [ + { id: "vite", label: "Vite" }, + { id: "tailwind", label: "Tailwind" }, + ], +}; + +const createProjectSettings = (techstack?: ProjectSettings["techstack"]): ProjectSettings => ({ + ...dashboardSettingsToProjectSettings(DEFAULT_DASHBOARD_SETTINGS), + ...(techstack ? { techstack } : {}), +}); + +const createCatalogEntries = (): TechstackCatalogEntrySettings[] => [ + ...DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.entries.map((entry) => ({ + ...entry, + items: entry.items.map((item) => ({ ...item })), + })), + { + ...customTechstack, + items: customTechstack.items.map((item) => ({ ...item })), + }, +]; + +const createSystemSettings = (projectSettings: ProjectSettings): SystemSettings => ({ + runtime: {} as SystemSettings["runtime"], + integrations: {} as SystemSettings["integrations"], + defaults: projectSettings, + techstackCatalog: { + defaultTechstackId: DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.defaultTechstackId, + entries: createCatalogEntries(), + }, + mcpTools: [], + customMcpServers: [], + modelPricing: { overrides: {} }, +}); + it("SettingsCategoryRail renders categories with proper aria-current semantics", () => { const mockCategories = [ { id: "general" as const, num: "01", label: "General", icon: SlidersHorizontal, description: "Test" } @@ -57,6 +100,26 @@ vi.mock("../panels/SettingsGeneralPanel", () => ({ expect(screen.queryByText("Jump directly into the area you need without digging through the full settings tree.")).not.toBeInTheDocument(); }); + it("SettingsCategoryRail includes the Techstacks category without changing selection semantics", () => { + const techstacks = CATEGORIES.find((category) => category.id === "techstacks"); + + expect(techstacks?.label).toBe("Techstacks"); + + render( + {}} + /> + ); + + const btn = screen.getByRole("button", { name: /Techstacks/ }); + expect(btn).toHaveAttribute("aria-current", "page"); + expect(btn).toHaveAttribute("aria-selected", "true"); + }); + it("SettingsCategoryRail subtracts the measured page-top margin from its desktop height", async () => { Object.defineProperty(window, "innerHeight", { configurable: true, value: 900 }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { @@ -592,6 +655,119 @@ describe("SettingsControls Accessibility", () => { expect(screen.getByText("Active panel").parentElement).toHaveStyle("--settings-active-panel-top: 148px"); }); + it("SettingsContentPanels routes the Techstacks category to the catalog panel", () => { + const projectSettings = createProjectSettings(); + render( + {}, + } as any} + /> + ); + + expect(screen.getByText("Techstacks Catalog")).toBeInTheDocument(); + expect(screen.getAllByText("Code UX Internal").length).toBeGreaterThan(0); + }); + + it("SettingsTechstacksPanel protects the built-in internal stack from removal", () => { + const projectSettings = createProjectSettings(); + render( + {}, + } as any} + /> + ); + + expect(screen.getByText("Built-in stack protected")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Remove Code UX Internal" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove Custom Web" })).toBeInTheDocument(); + }); + + it("SettingsTechstacksPanel lets project scope clear the selected stack to Unassigned", async () => { + const user = userEvent.setup(); + let latestProjectSettings = createProjectSettings({ + selectedTechstackId: "custom-web", + applicationKind: "web", + }); + const systemSettings = createSystemSettings(latestProjectSettings); + + const Harness = () => { + const [projectSettings, setProjectSettings] = useState(latestProjectSettings); + latestProjectSettings = projectSettings; + return ( + ProjectSettings) => setProjectSettings(recipe), + getFieldReset: () => undefined, + } as any} + /> + ); + }; + + render(); + + await user.click(screen.getByRole("radio", { name: /Unassigned/ })); + + expect(latestProjectSettings.techstack.selectedTechstackId).toBeNull(); + }); + + it("SettingsTechstacksPanel persists project stack and application kind through editable settings helpers", async () => { + const user = userEvent.setup(); + let latestProjectSettings = createProjectSettings({ + selectedTechstackId: null, + applicationKind: null, + }); + const systemSettings = createSystemSettings(latestProjectSettings); + + const Harness = () => { + const [projectSettings, setProjectSettings] = useState(latestProjectSettings); + latestProjectSettings = projectSettings; + return ( + ProjectSettings) => setProjectSettings(recipe), + getFieldReset: () => undefined, + } as any} + /> + ); + }; + + render(); + + await user.click(screen.getByRole("radio", { name: /Custom Web/ })); + await user.click(screen.getByRole("radio", { name: /Web app/ })); + + expect(latestProjectSettings.techstack).toEqual({ + selectedTechstackId: "custom-web", + applicationKind: "web", + }); + }); + it("SettingsPage keeps the scope controls in a sticky wrapping strip and passes its measured offset to the panel strip", () => { const source = readFileSync("dashboard/src/v2/SettingsPage.tsx", "utf8"); diff --git a/dashboard/src/v2/components/settings/panels/SettingsTechstacksPanel.tsx b/dashboard/src/v2/components/settings/panels/SettingsTechstacksPanel.tsx new file mode 100644 index 0000000000..4b5347fd39 --- /dev/null +++ b/dashboard/src/v2/components/settings/panels/SettingsTechstacksPanel.tsx @@ -0,0 +1,484 @@ +import type { FunctionComponent } from "preact"; +import { Layers3, ListChecks, ShieldCheck, Trash2 } from "lucide-preact"; +import type { SettingsPageState } from "../../../hooks/use-settings-page-state.js"; +import type { ProjectSettings, SystemSettings, TechstackCatalogEntrySettings, TechstackItemSettings } from "../../../../types.js"; +import { BUILTIN_CODE_UX_TECHSTACK_ID } from "../../../../../../src/repositories/settings-defaults.js"; +import { ActionButton, NoticePanel } from "../SettingsSurface.js"; +import { ActionFeedbackRegion } from "../../ui/ActionFeedbackRegion.js"; +import { PillChoiceGroup, TextInput } from "../SettingsFormFields.js"; +import { Row, SectionCard, getBadge as getBadgeHelper, getFieldBadge as getFieldBadgeHelper } from "./SharedPanelComponents.js"; + +const UNASSIGNED_VALUE = "__unassigned__"; +const UNSPECIFIED_KIND_VALUE = "__unspecified__"; +const TECHSTACK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/; + +const isValidTechstackId = (value: string): boolean => TECHSTACK_ID_PATTERN.test(value.trim()); + +const createUniqueId = (entries: TechstackCatalogEntrySettings[], baseId: string): string => { + const existingIds = new Set(entries.map((entry) => entry.id)); + if (!existingIds.has(baseId)) { + return baseId; + } + let suffix = 2; + while (existingIds.has(`${baseId}-${suffix}`)) { + suffix += 1; + } + return `${baseId}-${suffix}`; +}; + +const entryIdError = (entry: TechstackCatalogEntrySettings, entries: TechstackCatalogEntrySettings[]): string | undefined => { + const id = entry.id.trim(); + if (!id) { + return "Stack id is required."; + } + if (!isValidTechstackId(id)) { + return "Use letters, numbers, underscores, or hyphens, up to 80 characters."; + } + if (entries.some((candidate) => candidate !== entry && candidate.id.trim() === id)) { + return "Stack id must be unique."; + } + return undefined; +}; + +const itemIdError = (item: TechstackItemSettings, items: TechstackItemSettings[]): string | undefined => { + const id = item.id.trim(); + if (!id) { + return "Item id is required."; + } + if (!isValidTechstackId(id)) { + return "Use letters, numbers, underscores, or hyphens, up to 80 characters."; + } + if (items.some((candidate) => candidate !== item && candidate.id.trim() === id)) { + return "Item id must be unique in this stack."; + } + return undefined; +}; + +const entryHasValidationError = ( + entry: TechstackCatalogEntrySettings, + entries: TechstackCatalogEntrySettings[], +): boolean => Boolean( + entryIdError(entry, entries) + || !entry.label.trim() + || entry.items.length === 0 + || entry.items.some((item) => itemIdError(item, entry.items) || !item.label.trim()) +); + +const updateCatalogEntry = ( + current: SystemSettings, + entryId: string, + recipe: (entry: TechstackCatalogEntrySettings) => TechstackCatalogEntrySettings, +): SystemSettings => { + let nextEntryId = entryId; + const entries = current.techstackCatalog.entries.map((entry) => { + if (entry.id !== entryId) { + return entry; + } + const nextEntry = recipe(entry); + nextEntryId = nextEntry.id; + return nextEntry; + }); + + return { + ...current, + techstackCatalog: { + defaultTechstackId: current.techstackCatalog.defaultTechstackId === entryId + ? nextEntryId + : current.techstackCatalog.defaultTechstackId, + entries, + }, + defaults: { + ...current.defaults, + techstack: { + ...current.defaults.techstack, + selectedTechstackId: current.defaults.techstack.selectedTechstackId === entryId + ? nextEntryId + : current.defaults.techstack.selectedTechstackId, + }, + }, + }; +}; + +const updateCatalogItem = ( + current: SystemSettings, + entryId: string, + itemId: string, + recipe: (item: TechstackItemSettings) => TechstackItemSettings, +): SystemSettings => updateCatalogEntry(current, entryId, (entry) => ({ + ...entry, + items: entry.items.map((item) => item.id === itemId ? recipe(item) : item), +})); + +const StackSummary: FunctionComponent<{ entry: TechstackCatalogEntrySettings; builtin: boolean }> = ({ entry, builtin }) => ( +
+
+ {entry.label} + + {entry.id} + + {builtin ? ( + + + Built-in + + ) : null} +
+
+ {entry.items.length > 0 ? entry.items.map((item) => ( + + {item.label} + + )) : ( + Add at least one technology item. + )} +
+
+); + +const SystemTechstacks: FunctionComponent<{ + state: SettingsPageState; + systemSettings: SystemSettings; +}> = ({ state, systemSettings }) => { + const { activeSaving, updateSystem } = state; + const entries = systemSettings.techstackCatalog.entries; + const defaultId = systemSettings.techstackCatalog.defaultTechstackId; + const defaultMissing = !entries.some((entry) => entry.id === defaultId); + const catalogHasValidationErrors = defaultMissing || entries.some((entry) => entryHasValidationError(entry, entries)); + + const addStack = (): void => { + updateSystem((current) => { + const id = createUniqueId(current.techstackCatalog.entries, "custom-stack"); + return { + ...current, + techstackCatalog: { + ...current.techstackCatalog, + entries: [ + ...current.techstackCatalog.entries, + { + id, + label: "Custom Stack", + items: [{ id: "primary-framework", label: "Primary framework" }], + }, + ], + }, + }; + }); + }; + + const removeStack = (entryId: string): void => { + if (entryId === BUILTIN_CODE_UX_TECHSTACK_ID) { + return; + } + updateSystem((current) => { + const entries = current.techstackCatalog.entries.filter((entry) => entry.id !== entryId); + return { + ...current, + techstackCatalog: { + defaultTechstackId: current.techstackCatalog.defaultTechstackId === entryId + ? BUILTIN_CODE_UX_TECHSTACK_ID + : current.techstackCatalog.defaultTechstackId, + entries, + }, + defaults: { + ...current.defaults, + techstack: { + ...current.defaults.techstack, + selectedTechstackId: current.defaults.techstack.selectedTechstackId === entryId + ? null + : current.defaults.techstack.selectedTechstackId, + }, + }, + }; + }); + }; + + return ( +
+ } + actions={( + + )} + > + + updateSystem((current) => ({ + ...current, + techstackCatalog: { + ...current.techstackCatalog, + defaultTechstackId: value, + }, + }))} + options={entries.map((entry) => ({ + value: entry.id, + label: entry.label, + hint: entry.id === BUILTIN_CODE_UX_TECHSTACK_ID ? "Protected built-in stack." : entry.id, + }))} + /> + + + + +
+ {entries.map((entry) => { + const builtin = entry.id === BUILTIN_CODE_UX_TECHSTACK_ID; + const idError = entryIdError(entry, entries); + const labelError = entry.label.trim() ? undefined : "Stack name is required."; + const itemsError = entry.items.length > 0 ? undefined : "Add at least one technology item."; + + return ( +
+
+ + {!builtin ? ( + removeStack(entry.id)} + /> + ) : null} +
+ + {builtin ? ( + + The Code UX internal stack is restored by settings normalization and cannot be edited or removed. + + ) : ( + <> +
+ updateSystem((current) => updateCatalogEntry(current, entry.id, (currentEntry) => ({ + ...currentEntry, + id: value, + })))} + /> + updateSystem((current) => updateCatalogEntry(current, entry.id, (currentEntry) => ({ + ...currentEntry, + label: value, + })))} + /> +
+ +
+
+
+ + Tech items +
+ updateSystem((current) => updateCatalogEntry(current, entry.id, (currentEntry) => { + const id = createUniqueId(currentEntry.items.map((item) => ({ id: item.id, label: item.label, items: [] })), "technology"); + return { + ...currentEntry, + items: [...currentEntry.items, { id, label: "Technology" }], + }; + }))} + /> +
+ {itemsError ?
{itemsError}
: null} + {entry.items.map((item) => { + const idError = itemIdError(item, entry.items); + const labelError = item.label.trim() ? undefined : "Item name is required."; + return ( +
+ updateSystem((current) => updateCatalogItem(current, entry.id, item.id, (currentItem) => ({ + ...currentItem, + id: value, + })))} + /> + updateSystem((current) => updateCatalogItem(current, entry.id, item.id, (currentItem) => ({ + ...currentItem, + label: value, + })))} + /> + +
+ ); + })} +
+ + )} +
+ ); + })} +
+
+
+ ); +}; + +const ProjectTechstacks: FunctionComponent<{ + state: SettingsPageState; + projectSettings: ProjectSettings; +}> = ({ state, projectSettings }) => { + const { + activeScope, + activeSaving, + projectSources, + systemSettings, + updateEditableSettings, + getFieldReset, + } = state; + const entries = systemSettings?.techstackCatalog.entries ?? []; + const selectedStackId = projectSettings.techstack.selectedTechstackId ?? UNASSIGNED_VALUE; + const applicationKind = projectSettings.techstack.applicationKind ?? UNSPECIFIED_KIND_VALUE; + const selectedStackMissing = projectSettings.techstack.selectedTechstackId + ? !entries.some((entry) => entry.id === projectSettings.techstack.selectedTechstackId) + : false; + const getBadge = (...prefixes: string[]) => getBadgeHelper(activeScope, projectSources, ...prefixes); + const getFieldBadge = (path: string) => getFieldBadgeHelper(activeScope, projectSources, path); + + return ( +
+ }> + + Existing and imported projects keep `Unassigned` until you select a stack here or a setup/package scan detects one later. + + + updateEditableSettings((current) => ({ + ...current, + techstack: { + ...current.techstack, + selectedTechstackId: value === UNASSIGNED_VALUE ? null : value, + }, + }))} + options={[ + { value: UNASSIGNED_VALUE, label: "Unassigned", hint: "Leave imported or unknown projects unclassified." }, + ...entries.map((entry) => ({ + value: entry.id, + label: entry.label, + hint: entry.items.map((item) => item.label).join(", ") || entry.id, + })), + ]} + /> + + + updateEditableSettings((current) => ({ + ...current, + techstack: { + ...current.techstack, + applicationKind: value === "web" || value === "desktop" ? value : null, + }, + }))} + options={[ + { value: UNSPECIFIED_KIND_VALUE, label: "Unspecified", hint: "No app-kind classification yet." }, + { value: "web", label: "Web app", hint: "Browser or hosted web runtime." }, + { value: "desktop", label: "Desktop app", hint: "Electron or desktop-shell runtime." }, + ]} + /> + + +
+ ); +}; + +export const SettingsTechstacksPanel: FunctionComponent<{ state: SettingsPageState }> = ({ state }) => { + const { activeScope, projectSettings, selectedProject, systemSettings } = state; + + if (!systemSettings) { + return null; + } + + if (activeScope === "system") { + return ; + } + + if (!selectedProject || !projectSettings) { + return ( + + Select a project first to assign or clear its techstack. + + ); + } + + return ; +}; diff --git a/dashboard/src/v2/hooks/use-settings-page-state.ts b/dashboard/src/v2/hooks/use-settings-page-state.ts index 5d4c15d45e..d28c946701 100644 --- a/dashboard/src/v2/hooks/use-settings-page-state.ts +++ b/dashboard/src/v2/hooks/use-settings-page-state.ts @@ -39,7 +39,7 @@ import type { AgentAvatarConfig, AgentPreset } from "../types.js"; import { AlertTriangle, Bot, BrainCircuit, Cpu, Plug, Settings, SlidersHorizontal, Target } from "lucide-preact"; type SettingsScope = "system" | "project"; -type CategoryId = "general" | "appearance" | "models" | "sprint" | "browser" | "agents" | "memory" | "integrations" | "mcp" | "danger"; +type CategoryId = "general" | "appearance" | "models" | "sprint" | "browser" | "techstacks" | "agents" | "memory" | "integrations" | "mcp" | "danger"; type AgentInstructionTemplateId = keyof ProjectSettings["agents"]["instructionTemplates"]; interface Category { diff --git a/dashboard/src/v2/lib/settings-search-index.ts b/dashboard/src/v2/lib/settings-search-index.ts index 981bc82604..c9026ccaeb 100644 --- a/dashboard/src/v2/lib/settings-search-index.ts +++ b/dashboard/src/v2/lib/settings-search-index.ts @@ -145,6 +145,26 @@ const BASE_CATEGORY_TERMS: Record = { "visibility", "proxy", ], + techstacks: [ + "techstack", + "techstacks", + "stack", + "catalog", + "catalogue", + "default stack", + "preact", + "tanstack router", + "gsap", + "three.js", + "three js", + "lucide", + "lucide icons", + "web app", + "desktop app", + "package scan", + "application kind", + "unassigned", + ], agents: [ "agent", "agents", diff --git a/dashboard/src/v2/lib/settings-subcategory-docs.ts b/dashboard/src/v2/lib/settings-subcategory-docs.ts index b6453b0f41..9c5d9220fd 100644 --- a/dashboard/src/v2/lib/settings-subcategory-docs.ts +++ b/dashboard/src/v2/lib/settings-subcategory-docs.ts @@ -241,6 +241,17 @@ export const SETTINGS_SUBCATEGORY_DOCS = { risks: "Port collisions or wrong startup scripts prevent previews from becoming reachable.", relatedDocs: ["Browser Preview", "Security Hardening"], }, + techstacks: { + id: "techstacks", + title: "Techstacks", + titleAliases: ["Techstacks Catalog", "Project Techstack"], + docsHref: settingsDocsPath("techstacks"), + summary: "Manages the system techstack catalog and per-project techstack/application-kind assignment.", + controls: "System scope owns stack entries, default-stack selection, and technology items; project scope chooses a stack, clears to Unassigned, and selects web or desktop app kind.", + recommended: "Keep imported projects unassigned until setup or an operator identifies the stack; use the built-in Code UX stack only for Code UX-style Preact dashboards.", + risks: "Deleting custom stacks clears references to them, while the built-in Code UX internal stack is protected and restored by settings normalization.", + relatedDocs: ["Configuration and Storage", "Settings Reference"], + }, "project-markdown-mirror": { id: "project-markdown-mirror", title: "Project Markdown Mirror", diff --git a/docs-web/user/dashboard/settings.md b/docs-web/user/dashboard/settings.md index 58d08204bf..55ea21c7bd 100644 --- a/docs-web/user/dashboard/settings.md +++ b/docs-web/user/dashboard/settings.md @@ -35,6 +35,7 @@ The category rail on the left includes: | **MCP tools** | Per-tool enable / disable. | | **Memory** | Active embedding model selection. | | **Agents** | Agent routing, markdown mirroring, persistent skill storage, storage attachments, and self-reflection criteria. | +| **Techstacks** | System catalog management, protected built-in stack, project stack assignment, and web/desktop application kind. | | **Appearance** | Theme, navigation mode override, dashboard density. | | **Limits** | `maxFailures` emergency stop threshold and other safety caps. | @@ -485,6 +486,23 @@ Related docs: - [Browser Preview](./browser.md) - [Security Hardening](../troubleshooting.md) +### Techstacks + + + +Manages the system techstack catalog and per-project techstack/application-kind assignment. + +**What it controls:** System scope owns stack ids, stack names, technology items, and the catalog default. Project scope stores only the selected stack id and application kind, with explicit `Unassigned` support. + +**Recommended defaults:** Keep imported projects unassigned until setup detection or an operator chooses a stack. Use the built-in Code UX internal stack only for Code UX-style Preact dashboards; create custom stacks for other project families. + +**Risks and gotchas:** The built-in `code-ux-internal` stack cannot be removed. Removing a custom stack also clears system-default references to it; project assignments should be reviewed before deleting stacks that are in active use. + +Related docs: + +- [Configuration and Storage](../../developer/settings-reference.md) +- [Settings Reference](../../developer/settings-reference.md) + ### Project Markdown Mirror diff --git a/docs/dashboard/design-system-settings.md b/docs/dashboard/design-system-settings.md index 24db2d9c9d..198519f700 100644 --- a/docs/dashboard/design-system-settings.md +++ b/docs/dashboard/design-system-settings.md @@ -42,6 +42,7 @@ This document defines the visual patterns and rules for the Settings workspace. * Integrations matches provider credential terms, API keys, authentication, local auth-copy mounts, dashboard login, GitHub/GitLab/Jira style git-host connections, repository, pull request, issue, token language, and Code UX Agent-specific clarification/CI autofix controls. * Sprint & Git matches routing terms for branch naming, default/feature branches, merge gates, CI/autofix, execution runtime, Docker cleanup, QA, and quality assurance. * Browser Preview, Memory, Agents, and MCP must remain searchable through their user-facing terms: preview/container/port/proxy, memory/embedding/claims/remediation, prompt/template/instruction/markdown authoring, and MCP server/tool/stdio/http/SSE/built-in tool access. + * Techstacks must remain searchable through catalog and project-assignment terms: techstack, stack, Preact, TanStack Router, GSAP, Three.js, Lucide, web app, desktop app, and package scan. * Agents search terms also include persistent skills, skill storage, storage attachment, self-reflection, criteria, planning rating, and QA rating so users can find disabled-by-default configuration before enabling it. * The Smart Find status text uses `role="status"` with `aria-live="polite"` and includes match previews so assistive technology users receive the same filtered-category context as sighted users. diff --git a/docs/settings/subcategories/index.md b/docs/settings/subcategories/index.md index e91437154e..b284d05f10 100644 --- a/docs/settings/subcategories/index.md +++ b/docs/settings/subcategories/index.md @@ -24,6 +24,7 @@ Every visible Settings subcategory card has card-level help and a documentation - [Workspace Hygiene](./workspace-hygiene.md) - [Workspace Visibility](./workspace-visibility.md) - [Runtime Limits](./runtime-limits.md) +- [Techstacks](./techstacks.md) - [Project Markdown Mirror](./project-markdown-mirror.md) - [Agent Routing](./agent-routing.md) - [Memory System](./memory-system.md) diff --git a/docs/settings/subcategories/techstacks.md b/docs/settings/subcategories/techstacks.md new file mode 100644 index 0000000000..150c6b6324 --- /dev/null +++ b/docs/settings/subcategories/techstacks.md @@ -0,0 +1,24 @@ +# Techstacks + +Manages the system techstack catalog and per-project techstack/application-kind assignment. + +## What It Controls + +System scope owns the catalog: stack ids, stack names, technology items, and the catalog default. Project scope stores only the selected stack id and application kind, with explicit `Unassigned` support. + +## Recommended Defaults + +Keep imported projects unassigned until setup detection or an operator chooses a stack. Use the built-in Code UX internal stack only for Code UX-style Preact dashboards; create custom stacks for other project families. + +## Risks And Gotchas + +The built-in `code-ux-internal` stack cannot be removed. Removing a custom stack also clears system-default references to it; project assignments should be reviewed before deleting stacks that are in active use. + +## Dashboard Link + +Open this subcategory from the dashboard docs route at `/docs/user/dashboard/settings#techstacks`. The Settings card header links to the matching published docs anchor. + +## Related Docs + +- [Configuration and Storage](../configuration-and-storage.md) +- [Settings Reference](../../developer/settings-reference.md) diff --git a/tests/dashboard/lib/settings-search-index.test.ts b/tests/dashboard/lib/settings-search-index.test.ts index 84b982e851..970a2ca799 100644 --- a/tests/dashboard/lib/settings-search-index.test.ts +++ b/tests/dashboard/lib/settings-search-index.test.ts @@ -60,6 +60,16 @@ describe("settings search index", () => { ["qa", ["models", "sprint"]], ["branch", ["sprint", "agents"]], ["browser", ["browser"]], + ["techstack", ["techstacks"]], + ["stack", ["techstacks"]], + ["preact", ["techstacks"]], + ["tanstack router", ["techstacks"]], + ["gsap", ["techstacks"]], + ["three.js", ["techstacks"]], + ["lucide", ["techstacks"]], + ["web app", ["techstacks"]], + ["desktop app", ["techstacks"]], + ["package scan", ["techstacks"]], ["memory", ["memory"]], ["persistent skills", ["agents"]], ["skill storage", ["agents"]],