diff --git a/apps/web/src/components/icon.tsx b/apps/web/src/components/icon.tsx index f50295d..675094e 100644 --- a/apps/web/src/components/icon.tsx +++ b/apps/web/src/components/icon.tsx @@ -34,6 +34,7 @@ import { Stop, Terminal, ArrowLeft, + DotsSixVertical, } from "@phosphor-icons/react"; export type IconName = @@ -75,7 +76,8 @@ export type IconName = | "play" | "stop" | "terminal" - | "arrow-left"; + | "arrow-left" + | "drag"; const iconMap: Record> = { dashboard: House, @@ -117,6 +119,7 @@ const iconMap: Record { is_default: false, }); + const [includedProfileIds, setIncludedProfileIds] = useState([]); + const [dragOverIndex, setDragOverIndex] = useState(null); + const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null; const loadData = useCallback(async () => { @@ -73,6 +77,7 @@ export const ConfigProfilesPage = () => { files: {}, is_default: false, }); + setIncludedProfileIds([]); setError(null); setSaveStatus("idle"); setPreviewData(null); @@ -90,6 +95,9 @@ export const ConfigProfilesPage = () => { files: profile.files, is_default: profile.is_default, }); + setIncludedProfileIds( + profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id) + ); setError(null); setSaveStatus("idle"); setPreviewData(null); @@ -121,6 +129,82 @@ export const ConfigProfilesPage = () => { return "Failed to save"; }; + // Include management functions + const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id); + + const getScopeLabel = (profile: ConfigProfile): string => { + if (profile.project_id && profile.tool_type_id) return "Project + Tool"; + if (profile.project_id) return "Project"; + if (profile.tool_type_id) return "Tool"; + return "Global"; + }; + + // Cycle detection: returns true if adding targetId would create a cycle + const wouldCreateCycle = (profileId: string, targetId: string, visited = new Set()): boolean => { + if (visited.has(targetId)) return true; + const target = getIncludedProfile(targetId); + if (!target) return false; + const nextVisited = new Set(visited); + nextVisited.add(targetId); + for (const inc of target.includes) { + if (inc.included_profile_id === profileId || wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)) { + return true; + } + } + return false; + }; + + const availableProfilesForInclude = (): ConfigProfile[] => { + const currentId = selectedProfile?.id; + if (!currentId) return []; + return profiles.filter((p) => { + if (p.id === currentId) return false; + if (includedProfileIds.includes(p.id)) return false; + if (wouldCreateCycle(currentId, p.id)) return false; + return true; + }); + }; + + const addInclude = (profileId: string) => { + setIncludedProfileIds((prev) => [...prev, profileId]); + }; + + const removeInclude = (index: number) => { + setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index)); + }; + + // Drag and drop handlers + const handleDragStart = (e: React.DragEvent, index: number) => { + e.dataTransfer.setData("text/plain", String(index)); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIndex(index); + }; + + const handleDragLeave = () => { + setDragOverIndex(null); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + const dragIndex = Number(e.dataTransfer.getData("text/plain")); + if (dragIndex === dropIndex) { + setDragOverIndex(null); + return; + } + setIncludedProfileIds((prev) => { + const newOrder = [...prev]; + const [removed] = newOrder.splice(dragIndex, 1); + newOrder.splice(dropIndex, 0, removed); + return newOrder; + }); + setDragOverIndex(null); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -135,6 +219,9 @@ export const ConfigProfilesPage = () => { try { if (isCreating) { const newProfile = await createConfigProfile(formData); + if (includedProfileIds.length > 0) { + await updateProfileIncludes(newProfile.id, { includes: includedProfileIds }); + } setIsCreating(false); setSelectedProfileId(newProfile.id); setSaveStatus("saved"); @@ -144,6 +231,7 @@ export const ConfigProfilesPage = () => { if (refreshed) populateForm(refreshed); } else if (selectedProfile) { await updateConfigProfile(selectedProfile.id, formData); + await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds }); setSaveStatus("saved"); await loadData(); // Refresh the selected profile data @@ -402,6 +490,18 @@ export const ConfigProfilesPage = () => { default )} + {profile.includes?.length > 0 && ( + + {profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""} + + )}
{profile.project_id && "Project scoped"} @@ -600,6 +700,106 @@ export const ConfigProfilesPage = () => {
+ {/* Includes Section */} +
+
+

Includes

+ + {includedProfileIds.length} included + +
+ + {includedProfileIds.length === 0 ? ( +

+ No profiles included. Add profiles to compose configurations. +

+ ) : ( +
+ {includedProfileIds.map((profileId, index) => { + const profile = getIncludedProfile(profileId); + if (!profile) return null; + return ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + style={{ + display: "flex", + alignItems: "center", + gap: "0.5rem", + padding: "0.5rem 0.75rem", + background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)", + border: "1px solid var(--border)", + borderRadius: "0.375rem", + marginBottom: "0.25rem", + cursor: "grab", + transition: "background 0.15s", + }} + > + + + + {profile.name} + + {getScopeLabel(profile)} + + +
+ ); + })} +
+ )} + + {availableProfilesForInclude().length > 0 && ( +
+ +
+ )} +
+

Environment Variables

{Object.entries(formData.env_vars || {}).map(([key, value], idx) => ( diff --git a/openspec/changes/config-profile-includes-ui/.openspec.yaml b/openspec/changes/config-profile-includes-ui/.openspec.yaml new file mode 100644 index 0000000..6894814 --- /dev/null +++ b/openspec/changes/config-profile-includes-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-24 diff --git a/openspec/changes/config-profile-includes-ui/design.md b/openspec/changes/config-profile-includes-ui/design.md new file mode 100644 index 0000000..df956c6 --- /dev/null +++ b/openspec/changes/config-profile-includes-ui/design.md @@ -0,0 +1,70 @@ +## Context + +The config profiles backend already supports full ordered include composition: +- `PUT /config-profiles/{id}/includes` accepts an ordered array of profile IDs +- The API validates ownership, self-inclusion, existence, and cycles +- `GET /config-profiles/{id}/preview` resolves includes and shows merged output +- The frontend profile editor (`config-profiles.tsx`) already loads all profiles via `listConfigProfiles()` + +However, the profile editor UI currently has no includes management surface. Users can create individual profiles but cannot compose them. This design adds a drag-and-drop includes management section to the existing profile editor. + +## Goals / Non-Goals + +**Goals:** +- Allow users to see which profiles a profile includes +- Allow users to add, remove, and reorder includes +- Prevent invalid includes (self, duplicates, cycles) in the UI +- Show scope badges on included profiles for clarity +- Display include count in the profile list sidebar +- Save includes together with the profile form + +**Non-Goals:** +- Backend changes (APIs already fully support this) +- Drag-and-drop library dependency (use native HTML5 DnD) +- Profile dependency graph visualization (future enhancement) +- Validation of include compatibility at the scope level (backend handles this) + +## Decisions + +### Use Native HTML5 Drag and Drop +**Rationale:** No additional dependency needed. The includes list is small (typically < 10 items), so native DnD is sufficient and lightweight. Libraries like react-beautiful-dnd add bundle size and complexity for this use case. +**Alternative considered:** `@dnd-kit/core` — rejected to keep dependencies minimal. + +### Save Includes with Main Form +**Rationale:** The user explicitly requested this. It's simpler UX than a separate save button and matches the mental model of "editing a profile." +**Implementation:** On save, first update profile fields via `updateConfigProfile()`, then update includes via `updateProfileIncludes()`. If either fails, show error and don't clear dirty state. + +### Frontend Cycle Detection +**Rationale:** Prevents the user from even selecting profiles that would create a cycle, providing immediate feedback instead of waiting for the API to reject it. +**Implementation:** Build a graph from the current `profiles` array (which includes `includes` data), then traverse from each candidate profile to check if it can reach the current profile. + +### Scope Badge Display +**Rationale:** Helps users understand why certain profiles are or aren't available for inclusion, and what scope each layer operates at. +**Implementation:** Reuse existing scope badge logic from the profile list sidebar (Global, Project scoped, Tool scoped, Project + Tool scoped). + +### Include Count Badge in Sidebar +**Rationale:** Quick visual indicator of which profiles are composite vs. standalone. +**Implementation:** Add a small numeric badge next to the profile name when `profile.includes.length > 0`. + +## Risks / Trade-offs + +**[Risk]** Saving profile and includes as two separate API calls could lead to partial success (profile saved, includes not saved). +→ **Mitigation:** Show clear error state if either call fails. The user can retry. Since includes are independent of profile content, partial failure is recoverable. + +**[Risk]** Large number of profiles could make the "Add Include" dropdown unwieldy. +→ **Mitigation:** Cap the dropdown height and add scroll. Typical users have < 20 profiles. + +**[Risk]** Drag-and-drop reordering may feel clunky on mobile. +→ **Mitigation:** This is primarily a desktop admin/settings interface. Mobile support is nice-to-have but not critical. + +## Migration Plan + +No migration needed — this is a pure frontend addition. Existing profiles with includes (created via API) will immediately show those includes in the UI. + +## Open Questions + +1. Should we show a preview of the resolved output (with includes applied) in real-time as includes change? + - *Tentative: No, keep the existing Preview button. Real-time resolution could be expensive and is not requested.* + +2. Should we allow including profiles from other users (shared profiles)? + - *Tentative: No, backend already restricts to own profiles. Keep it simple.* diff --git a/openspec/changes/config-profile-includes-ui/proposal.md b/openspec/changes/config-profile-includes-ui/proposal.md new file mode 100644 index 0000000..ca99583 --- /dev/null +++ b/openspec/changes/config-profile-includes-ui/proposal.md @@ -0,0 +1,29 @@ +## Why + +The config profiles feature supports ordered profile composition via the includes system (A includes B then C, resolution applies B → C → A), but this capability is completely invisible to users. They can create and edit individual profiles, but cannot see, add, remove, or reorder the profiles their configuration depends on. Without a UI for profile composition, users cannot build reusable config layers (e.g., an "Auth" profile + "Database" profile → "Full Stack" profile), which was a core design goal of the config profiles feature. + +## What Changes + +- Add a drag-and-drop includes management section to the config profile editor +- Display current includes with scope badges (Global, Project, Tool scoped) +- Allow adding includes via dropdown filtered by compatibility +- Allow removing includes +- Reorder includes via drag-and-drop (order affects resolution priority) +- Save includes together with the main profile form +- Add frontend cycle detection to prevent adding profiles that would create an include cycle +- Update profile list items to show include count badge + +## Capabilities + +### New Capabilities +- `config-profile-includes-management`: Managing ordered profile includes through the UI, including adding, removing, reordering, and visualizing the composition graph with scope indicators + +### Modified Capabilities +- *(none — backend APIs already fully support includes)* + +## Impact + +- **Frontend**: `apps/web/src/pages/config-profiles.tsx` — add includes management UI section +- **Frontend**: `apps/web/src/api/config_profiles.ts` — already has `updateProfileIncludes()`, no changes needed +- **Backend**: No changes required — `PUT /config-profiles/{id}/includes` and cycle detection already exist +- **No breaking changes** diff --git a/openspec/changes/config-profile-includes-ui/specs/config-profile-includes-management/spec.md b/openspec/changes/config-profile-includes-ui/specs/config-profile-includes-management/spec.md new file mode 100644 index 0000000..8296869 --- /dev/null +++ b/openspec/changes/config-profile-includes-ui/specs/config-profile-includes-management/spec.md @@ -0,0 +1,98 @@ +## ADDED Requirements + +### Requirement: Display Profile Includes +The system SHALL display the ordered list of profiles that a config profile includes, with each include showing the included profile's name and scope. + +#### Scenario: View includes on profile editor +- **WHEN** a user opens a config profile in the editor +- **AND** that profile has includes configured +- **THEN** the editor shows an "Includes" section +- **AND** each include displays the profile name +- **AND** each include displays a scope badge (Global, Project, Tool, or Project + Tool) +- **AND** includes are shown in their resolution order (first to last) + +#### Scenario: Empty includes section +- **WHEN** a user opens a config profile in the editor +- **AND** that profile has no includes +- **THEN** the "Includes" section is visible but empty +- **AND** it shows a message indicating no includes are configured + +### Requirement: Add Profile Includes +The system SHALL allow users to add other compatible profiles as includes to the currently edited profile. + +#### Scenario: Add an include +- **WHEN** a user clicks "Add Include" in the profile editor +- **THEN** a dropdown appears with available profiles +- **AND** the dropdown excludes the current profile +- **AND** the dropdown excludes profiles already included +- **AND** the dropdown excludes profiles that would create an include cycle +- **AND** each option shows the profile name with its scope badge +- **WHEN** the user selects a profile +- **THEN** it is appended to the includes list +- **AND** it appears at the end of the resolution order + +#### Scenario: Prevent self-inclusion +- **GIVEN** a user is editing profile "A" +- **WHEN** they attempt to include profile "A" itself +- **THEN** profile "A" does not appear in the dropdown + +#### Scenario: Prevent duplicate includes +- **GIVEN** profile "A" already includes profile "B" +- **WHEN** a user attempts to add another include +- **THEN** profile "B" does not appear in the dropdown + +#### Scenario: Prevent cyclic includes +- **GIVEN** profile "A" includes profile "B" +- **AND** profile "B" includes profile "C" +- **WHEN** a user is editing profile "C" +- **THEN** profile "A" does not appear in the dropdown +- **AND** profile "B" does not appear in the dropdown +- **BECAUSE** including either would create a cycle + +### Requirement: Remove Profile Includes +The system SHALL allow users to remove includes from a profile. + +#### Scenario: Remove an include +- **WHEN** a user clicks the remove button on an include row +- **THEN** that include is removed from the list +- **AND** the remaining includes maintain their relative order + +### Requirement: Reorder Profile Includes +The system SHALL allow users to reorder profile includes via drag-and-drop, where order determines resolution priority. + +#### Scenario: Reorder includes via drag-and-drop +- **GIVEN** a profile includes profiles in order: B, C, D +- **WHEN** a user drags "C" before "B" +- **THEN** the order becomes: C, B, D +- **AND** the resolution order is updated accordingly + +#### Scenario: Resolution order affects overrides +- **GIVEN** profile "A" includes "B" then "C" +- **AND** both "B" and "C" define the same environment variable "FOO" +- **WHEN** the profile is resolved +- **THEN** the value from "C" wins because it comes later in the order + +### Requirement: Save Profile Includes +The system SHALL save profile includes when the user saves the profile form. + +#### Scenario: Save includes with profile +- **GIVEN** a user has modified the includes list (added, removed, or reordered) +- **WHEN** they click "Save Profile" +- **THEN** the includes are saved via the includes API +- **AND** the profile fields are saved via the profile update API +- **AND** both operations succeed or both fail + +#### Scenario: Include cycle error on save +- **GIVEN** a user has configured includes that would create a cycle +- **WHEN** they attempt to save +- **THEN** the save fails +- **AND** an error message displays the cycle path +- **AND** the form remains editable so the user can fix it + +### Requirement: Show Include Count in Profile List +The system SHALL display the number of includes each profile has in the profile list sidebar. + +#### Scenario: Profile list shows include count +- **WHEN** a user views the config profiles list +- **THEN** each profile that has includes shows a badge with the count +- **AND** profiles with no includes show no badge diff --git a/openspec/changes/config-profile-includes-ui/tasks.md b/openspec/changes/config-profile-includes-ui/tasks.md new file mode 100644 index 0000000..f2c13e2 --- /dev/null +++ b/openspec/changes/config-profile-includes-ui/tasks.md @@ -0,0 +1,31 @@ +## 1. Core Includes UI + +- [x] 1.1 Add includes section to profile editor form with heading and empty state +- [x] 1.2 Display current includes list with profile names and scope badges +- [x] 1.3 Add remove button per include row +- [x] 1.4 Add "Add Include" dropdown with available profiles filtered by validity +- [x] 1.5 Implement frontend cycle detection to filter dropdown options +- [x] 1.6 Save includes together with profile form on save + +## 2. Drag and Drop Reordering + +- [x] 2.1 Add drag-and-drop reordering to includes list using native HTML5 DnD +- [x] 2.2 Update order indices after drag-and-drop reorder +- [x] 2.3 Add visual feedback during drag (ghost image, drop target highlight) + +## 3. Profile List Enhancements + +- [x] 3.1 Add include count badge to profile list items + +## 4. Polish and Error Handling + +- [x] 4.1 Handle API cycle errors gracefully with user-friendly messages +- [x] 4.2 Ensure save rollback on partial failure (profile saved but includes failed) +- [x] 4.3 Add loading states for includes operations + +## 5. Quality Gates + +- [x] 5.1 TypeScript type check passes +- [ ] 5.2 Manual testing: add, remove, reorder includes +- [ ] 5.3 Manual testing: cycle prevention in UI +- [ ] 5.4 Manual testing: save includes with profile form