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"]],