diff --git a/AGENTS.md b/AGENTS.md index 4be424c9d8..8f419f5e5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -869,6 +869,23 @@ Prior versions used a simpler JSON format (not JSON-RPC). For compatibility refe 6. **Validate messages** - Check for required fields before processing 7. **Set exit codes** - 0 for success, non-zero for errors +### Frontend Data Loading (SSR + WS) + +For settings + dashboard data, use a consistent pattern: + +1. **SSR fetch in layout/page** → hydrate the store via `StateHydrator`. +2. **Components read from store only** (no local fetches in UI components). +3. **WebSocket events update the store** for real-time sync. + +Guidelines: +- Fetch once in the server component (e.g. `apps/web/app/settings/layout.tsx`) and hydrate. +- Map API responses into store-friendly shapes (e.g. agent profiles → `{ id, label, agent_id }`). +- Use WS handlers to patch lists; avoid making HTTP requests inside WS handlers. +- Prefer WS events to include full entity payloads so clients do not need follow-up HTTP fetches. +- Only re-fetch when the server cannot emit the full data and document why. +- Avoid request/response WS calls inside UI components. +- UI components should read from the store only; perform HTTP fetches in hooks or store actions. + ## Local Agent Discovery Kandev checks a bundled discovery config to detect locally installed agents. Each agent entry lists OS-specific installation paths (Linux, Windows, macOS). At runtime, the backend selects the paths for the current OS, expands environment variables and `~`, and marks the agent as available if any configured file or directory exists. diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 0b14ec0245..f50cf86fbd 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -258,14 +258,22 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest return &result, nil } -func (c *Controller) DeleteProfile(ctx context.Context, id string) error { +func (c *Controller) DeleteProfile(ctx context.Context, id string) (*dto.AgentProfileDTO, error) { + profile, err := c.repo.GetAgentProfile(ctx, id) + if err != nil { + if strings.Contains(err.Error(), "agent profile not found") { + return nil, ErrAgentProfileNotFound + } + return nil, err + } if err := c.repo.DeleteAgentProfile(ctx, id); err != nil { if strings.Contains(err.Error(), "agent profile not found") { - return ErrAgentProfileNotFound + return nil, ErrAgentProfileNotFound } - return err + return nil, err } - return nil + result := toProfileDTO(profile) + return &result, nil } func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO { diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 46a0b7c373..4797561f70 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -200,8 +200,7 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { } if h.hub != nil { notification, _ := ws.NewNotification(ws.ActionAgentProfileCreated, gin.H{ - "profile_id": resp.ID, - "agent_id": resp.AgentID, + "profile": resp, }) h.hub.Broadcast(notification) } @@ -241,8 +240,7 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { } if h.hub != nil { notification, _ := ws.NewNotification(ws.ActionAgentProfileUpdated, gin.H{ - "profile_id": resp.ID, - "agent_id": resp.AgentID, + "profile": resp, }) h.hub.Broadcast(notification) } @@ -250,7 +248,8 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { } func (h *Handlers) httpDeleteProfile(c *gin.Context) { - if err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id")); err != nil { + profile, err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id")) + if err != nil { if err == controller.ErrAgentProfileNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) return @@ -261,7 +260,7 @@ func (h *Handlers) httpDeleteProfile(c *gin.Context) { } if h.hub != nil { notification, _ := ws.NewNotification(ws.ActionAgentProfileDeleted, gin.H{ - "profile_id": c.Param("id"), + "profile": profile, }) h.hub.Broadcast(notification) } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 4971116781..29aea30ac8 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -82,6 +82,7 @@ type EnvironmentDTO struct { ID string `json:"id"` Name string `json:"name"` Kind models.EnvironmentKind `json:"kind"` + IsSystem bool `json:"is_system"` WorktreeRoot string `json:"worktree_root,omitempty"` ImageTag string `json:"image_tag,omitempty"` Dockerfile string `json:"dockerfile,omitempty"` @@ -291,6 +292,7 @@ func FromEnvironment(environment *models.Environment) EnvironmentDTO { ID: environment.ID, Name: environment.Name, Kind: environment.Kind, + IsSystem: environment.IsSystem, WorktreeRoot: environment.WorktreeRoot, ImageTag: environment.ImageTag, Dockerfile: environment.Dockerfile, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index dae91a6a9b..b68886480a 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -280,6 +280,7 @@ type Environment struct { ID string `json:"id"` Name string `json:"name"` Kind EnvironmentKind `json:"kind"` + IsSystem bool `json:"is_system"` WorktreeRoot string `json:"worktree_root,omitempty"` ImageTag string `json:"image_tag,omitempty"` Dockerfile string `json:"dockerfile,omitempty"` diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 43fc5ea4b0..057908be95 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -111,6 +111,7 @@ func (r *SQLiteRepository) initSchema() error { id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, + is_system INTEGER NOT NULL DEFAULT 0, worktree_root TEXT DEFAULT '', image_tag TEXT DEFAULT '', dockerfile TEXT DEFAULT '', @@ -282,6 +283,9 @@ func (r *SQLiteRepository) initSchema() error { if err := r.ensureColumn("columns", "color", "TEXT NOT NULL DEFAULT ''"); err != nil { return err } + if err := r.ensureColumn("environments", "is_system", "INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } // Ensure new comment columns exist for existing databases if err := r.ensureColumn("task_comments", "type", "TEXT NOT NULL DEFAULT 'message'"); err != nil { @@ -515,6 +519,12 @@ func (r *SQLiteRepository) ensureDefaultExecutorsAndEnvironments() error { return err } } + } else { + if _, err := r.db.ExecContext(ctx, ` + UPDATE executors SET is_system = 1 WHERE id = ? + `, models.ExecutorIDLocalPC); err != nil { + return err + } } var envCount int @@ -524,9 +534,40 @@ func (r *SQLiteRepository) ensureDefaultExecutorsAndEnvironments() error { if envCount == 0 { now := time.Now().UTC() if _, err := r.db.ExecContext(ctx, ` - INSERT INTO environments (id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, "~/kandev", "", "", "{}", now, now); err != nil { + INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, boolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { + return err + } + } else { + var localCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments WHERE id = ?", models.EnvironmentIDLocal).Scan(&localCount); err != nil { + return err + } + if localCount == 0 { + now := time.Now().UTC() + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, boolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil { + return err + } + } + if _, err := r.db.ExecContext(ctx, ` + UPDATE environments + SET is_system = 1, + image_tag = '', + dockerfile = '', + build_config = '{}' + WHERE id = ? + `, models.EnvironmentIDLocal); err != nil { + return err + } + if _, err := r.db.ExecContext(ctx, ` + UPDATE environments + SET worktree_root = ? + WHERE id = ? AND (worktree_root IS NULL OR worktree_root = '') + `, "~/kandev", models.EnvironmentIDLocal); err != nil { return err } } @@ -1912,21 +1953,22 @@ func (r *SQLiteRepository) CreateEnvironment(ctx context.Context, environment *m } _, err = r.db.ExecContext(ctx, ` - INSERT INTO environments (id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, environment.ID, environment.Name, environment.Kind, environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt) + INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, environment.ID, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt) return err } func (r *SQLiteRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { environment := &models.Environment{} var buildConfigJSON string + var isSystem int err := r.db.QueryRowContext(ctx, ` - SELECT id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at + SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at FROM environments WHERE id = ? `, id).Scan( - &environment.ID, &environment.Name, &environment.Kind, &environment.WorktreeRoot, + &environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot, &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, &environment.CreatedAt, &environment.UpdatedAt, ) @@ -1936,6 +1978,7 @@ func (r *SQLiteRepository) GetEnvironment(ctx context.Context, id string) (*mode if err != nil { return nil, err } + environment.IsSystem = isSystem == 1 if buildConfigJSON != "" && buildConfigJSON != "{}" { if err := json.Unmarshal([]byte(buildConfigJSON), &environment.BuildConfig); err != nil { return nil, fmt.Errorf("failed to deserialize environment build config: %w", err) @@ -1953,9 +1996,9 @@ func (r *SQLiteRepository) UpdateEnvironment(ctx context.Context, environment *m } result, err := r.db.ExecContext(ctx, ` - UPDATE environments SET name = ?, kind = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ? + UPDATE environments SET name = ?, kind = ?, is_system = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ? WHERE id = ? - `, environment.Name, environment.Kind, environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID) + `, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID) if err != nil { return err } @@ -1980,7 +2023,7 @@ func (r *SQLiteRepository) DeleteEnvironment(ctx context.Context, id string) err func (r *SQLiteRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, kind, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at + SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at FROM environments ORDER BY created_at ASC `) if err != nil { @@ -1992,13 +2035,15 @@ func (r *SQLiteRepository) ListEnvironments(ctx context.Context) ([]*models.Envi for rows.Next() { environment := &models.Environment{} var buildConfigJSON string + var isSystem int if err := rows.Scan( - &environment.ID, &environment.Name, &environment.Kind, &environment.WorktreeRoot, + &environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot, &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, &environment.CreatedAt, &environment.UpdatedAt, ); err != nil { return nil, err } + environment.IsSystem = isSystem == 1 if buildConfigJSON != "" && buildConfigJSON != "{}" { if err := json.Unmarshal([]byte(buildConfigJSON), &environment.BuildConfig); err != nil { return nil, fmt.Errorf("failed to deserialize environment build config: %w", err) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 6630e89904..ee5b0caf27 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -963,6 +963,7 @@ func (s *Service) CreateEnvironment(ctx context.Context, req *CreateEnvironmentR ID: uuid.New().String(), Name: req.Name, Kind: req.Kind, + IsSystem: false, WorktreeRoot: req.WorktreeRoot, ImageTag: req.ImageTag, Dockerfile: req.Dockerfile, @@ -984,9 +985,9 @@ func (s *Service) UpdateEnvironment(ctx context.Context, id string, req *UpdateE if err != nil { return nil, err } - if environment.ID == models.EnvironmentIDLocal { - if req.Name != nil || req.Kind != nil || req.WorktreeRoot != nil { - return nil, fmt.Errorf("local environment cannot be modified") + if environment.IsSystem { + if req.Name != nil || req.Kind != nil || req.ImageTag != nil || req.Dockerfile != nil || req.BuildConfig != nil { + return nil, fmt.Errorf("system environments can only update the worktree root") } } if req.Name != nil { @@ -1016,13 +1017,13 @@ func (s *Service) UpdateEnvironment(ctx context.Context, id string, req *UpdateE } func (s *Service) DeleteEnvironment(ctx context.Context, id string) error { - if id == models.EnvironmentIDLocal { - return fmt.Errorf("local environment cannot be deleted") - } environment, err := s.repo.GetEnvironment(ctx, id) if err != nil { return err } + if environment.IsSystem { + return fmt.Errorf("system environments cannot be deleted") + } if err := s.repo.DeleteEnvironment(ctx, id); err != nil { return err } @@ -1197,6 +1198,7 @@ func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, "id": environment.ID, "name": environment.Name, "kind": environment.Kind, + "is_system": environment.IsSystem, "worktree_root": environment.WorktreeRoot, "image_tag": environment.ImageTag, "dockerfile": environment.Dockerfile, diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 33bb6deaaa..f6b66e3513 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,6 @@ import { PageClient } from '@/app/page-client'; import { StateHydrator } from '@/components/state-hydrator'; -import { fetchBoardSnapshot, fetchBoards, fetchUserSettings, fetchWorkspaces } from '@/lib/ssr/http'; +import { fetchBoardSnapshot, fetchUserSettings, listBoards, listRepositories, listWorkspaces } from '@/lib/http'; import { snapshotToState } from '@/lib/ssr/mapper'; import type { AppState } from '@/lib/state/store'; @@ -18,8 +18,8 @@ export default async function Page({ searchParams }: PageProps) { const boardIdParam = Array.isArray(boardParam) ? boardParam[0] : boardParam; const [workspaces, userSettingsResponse] = await Promise.all([ - fetchWorkspaces(), - fetchUserSettings().catch(() => null), + listWorkspaces({ cache: 'no-store' }), + fetchUserSettings({ cache: 'no-store' }).catch(() => null), ]); const userSettings = userSettingsResponse?.settings; const settingsWorkspaceId = userSettings?.workspace_id || null; @@ -58,7 +58,10 @@ export default async function Page({ searchParams }: PageProps) { ); } - const boardList = await fetchBoards(activeWorkspaceId); + const [boardList, repositoriesResponse] = await Promise.all([ + listBoards(activeWorkspaceId, { cache: 'no-store' }), + listRepositories(activeWorkspaceId, { cache: 'no-store' }).catch(() => ({ repositories: [] })), + ]); const boardId = boardList.boards.find((board) => board.id === boardIdParam)?.id ?? boardList.boards.find((board) => board.id === settingsBoardId)?.id ?? @@ -73,6 +76,11 @@ export default async function Page({ searchParams }: PageProps) { })), activeId: boardId ?? null, }, + repositories: { + itemsByWorkspaceId: { [activeWorkspaceId]: repositoriesResponse.repositories }, + loadingByWorkspaceId: { [activeWorkspaceId]: false }, + loadedByWorkspaceId: { [activeWorkspaceId]: true }, + }, }; if (!boardId) { @@ -84,7 +92,7 @@ export default async function Page({ searchParams }: PageProps) { ); } - const snapshot = await fetchBoardSnapshot(boardId); + const snapshot = await fetchBoardSnapshot(boardId, { cache: 'no-store' }); initialState = { ...initialState, ...snapshotToState(snapshot) }; return ( <> diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index e557e1ea04..e9a4089d2e 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { IconPlus } from '@tabler/icons-react'; @@ -17,14 +17,12 @@ import { createAgentAction, createAgentProfileAction, deleteAgentProfileAction, - listAgentDiscoveryAction, - listAgentsAction, updateAgentAction, updateAgentProfileAction, } from '@/app/actions/agents'; import type { Agent, AgentDiscovery, AgentProfile } from '@/lib/types/http'; import { generateUUID } from '@/lib/utils'; -import { useRequest } from '@/lib/http/use-request'; +import { useAppStore } from '@/components/state-provider'; type DraftAgent = Agent & { isNew?: boolean }; type DraftProfile = AgentProfile & { isNew?: boolean }; @@ -33,7 +31,6 @@ type AgentSetupFormProps = { initialAgent: DraftAgent; savedAgent: Agent | null; discoveryAgent: AgentDiscovery | undefined; - refreshAgents: () => Promise; onToastError: (error: unknown) => void; }; @@ -79,10 +76,12 @@ function AgentSetupForm({ initialAgent, savedAgent, discoveryAgent, - refreshAgents, onToastError, }: AgentSetupFormProps) { const router = useRouter(); + const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); + const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); const [draftAgent, setDraftAgent] = useState(initialAgent); const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); @@ -111,6 +110,27 @@ function AgentSetupForm({ return false; }, [draftAgent, savedAgent]); + const syncAgentsToStore = (nextAgents: Agent[]) => { + setSettingsAgents(nextAgents); + setAgentProfiles( + nextAgents.flatMap((agent) => + agent.profiles.map((profile) => ({ + id: profile.id, + label: `${agent.name} • ${profile.name}`, + agent_id: agent.id, + })) + ) + ); + }; + + const upsertAgent = (agent: Agent) => { + const exists = settingsAgents.some((item) => item.id === agent.id); + const nextAgents = exists + ? settingsAgents.map((item) => (item.id === agent.id ? agent : item)) + : [...settingsAgents, agent]; + syncAgentsToStore(nextAgents); + }; + const handleAddProfile = () => { setDraftAgent((current) => ({ ...current, @@ -145,7 +165,7 @@ function AgentSetupForm({ setSaveStatus('loading'); try { if (!savedAgent) { - const created = await createAgentAction({ + let created = await createAgentAction({ name: draftAgent.name, workspace_id: draftAgent.workspace_id, profiles: draftAgent.profiles.map((profile) => ({ @@ -157,9 +177,12 @@ function AgentSetupForm({ })), }); if ((draftAgent.mcp_config_path ?? '') !== (created.mcp_config_path ?? '')) { - await updateAgentAction(created.id, { mcp_config_path: draftAgent.mcp_config_path ?? '' }); + created = await updateAgentAction(created.id, { + mcp_config_path: draftAgent.mcp_config_path ?? '', + }); } - await refreshAgents(); + upsertAgent(created); + setDraftAgent(ensureProfiles(cloneAgent(created))); router.replace(`/settings/agents/${encodeURIComponent(created.name)}`); } else { const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {}; @@ -174,27 +197,32 @@ function AgentSetupForm({ } const savedProfilesById = new Map(savedAgent.profiles.map((profile) => [profile.id, profile])); + const nextProfiles: AgentProfile[] = []; for (const profile of draftAgent.profiles) { const savedProfile = savedProfilesById.get(profile.id); if (!savedProfile) { - await createAgentProfileAction(savedAgent.id, { + const createdProfile = await createAgentProfileAction(savedAgent.id, { name: profile.name, model: profile.model, auto_approve: profile.auto_approve, dangerously_skip_permissions: profile.dangerously_skip_permissions, plan: profile.plan, }); + nextProfiles.push(createdProfile); continue; } if (isProfileDirty(profile, savedProfile)) { - await updateAgentProfileAction(profile.id, { + const updatedProfile = await updateAgentProfileAction(profile.id, { name: profile.name, model: profile.model, auto_approve: profile.auto_approve, dangerously_skip_permissions: profile.dangerously_skip_permissions, plan: profile.plan, }); + nextProfiles.push(updatedProfile); + continue; } + nextProfiles.push(savedProfile); } for (const savedProfile of savedAgent.profiles) { const stillExists = draftAgent.profiles.some((profile) => profile.id === savedProfile.id); @@ -203,7 +231,14 @@ function AgentSetupForm({ } } - await refreshAgents(); + const nextAgent = { + ...savedAgent, + workspace_id: draftAgent.workspace_id ?? null, + mcp_config_path: draftAgent.mcp_config_path ?? '', + profiles: nextProfiles, + }; + upsertAgent(nextAgent); + setDraftAgent(ensureProfiles(cloneAgent(nextAgent))); } setSaveStatus('success'); } catch (error) { @@ -349,28 +384,16 @@ export default function AgentSetupPage() { const params = useParams(); const agentKey = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId; const decodedKey = decodeURIComponent(agentKey ?? ''); - const { run: runListDiscovery, data: discoveryData } = useRequest(listAgentDiscoveryAction); - const { run: runListAgents, data: agentsData } = useRequest(listAgentsAction); - - useEffect(() => { - Promise.all([runListDiscovery(), runListAgents()]).catch((error) => { - toast({ - title: 'Failed to load agent settings', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - }); - }, [runListAgents, runListDiscovery, toast]); + const discoveryAgents = useAppStore((state) => state.agentDiscovery.items); + const savedAgents = useAppStore((state) => state.settingsAgents.items); const discoveryAgent = useMemo(() => { - const agents = discoveryData?.agents ?? []; - return agents.find((agent) => agent.name === decodedKey); - }, [decodedKey, discoveryData?.agents]); + return discoveryAgents.find((agent) => agent.name === decodedKey); + }, [decodedKey, discoveryAgents]); const savedAgent = useMemo(() => { - const agents = agentsData?.agents ?? []; - return agents.find((agent) => agent.id === decodedKey || agent.name === decodedKey) ?? null; - }, [decodedKey, agentsData?.agents]); + return savedAgents.find((agent) => agent.id === decodedKey || agent.name === decodedKey) ?? null; + }, [decodedKey, savedAgents]); const initialAgent = useMemo(() => { if (!decodedKey) return null; @@ -394,7 +417,7 @@ export default function AgentSetupPage() { return null; }, [decodedKey, discoveryAgent, savedAgent]); - if (!initialAgent && (discoveryData?.agents?.length ?? 0) > 0) { + if (!initialAgent && discoveryAgents.length > 0) { return ( @@ -425,7 +448,6 @@ export default function AgentSetupPage() { initialAgent={initialAgent} savedAgent={savedAgent} discoveryAgent={discoveryAgent} - refreshAgents={runListAgents} onToastError={handleToastError} /> ); diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx index e5bc5e6a58..4b450916a1 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { IconTrash } from '@tabler/icons-react'; @@ -14,13 +14,9 @@ import { Switch } from '@kandev/ui/switch'; import { Textarea } from '@kandev/ui/textarea'; import { useToast } from '@/components/toast-provider'; import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; -import { - deleteAgentProfileAction, - listAgentsAction, - updateAgentProfileAction, -} from '@/app/actions/agents'; +import { deleteAgentProfileAction, updateAgentProfileAction } from '@/app/actions/agents'; import type { Agent, AgentProfile } from '@/lib/types/http'; -import { useRequest } from '@/lib/http/use-request'; +import { useAppStore } from '@/components/state-provider'; const AGENT_LABELS: Record = { claude: 'Claude', @@ -33,14 +29,28 @@ const AGENT_LABELS: Record = { type ProfileEditorProps = { agent: Agent; profile: AgentProfile; - onRefresh: () => Promise; }; -function ProfileEditor({ agent, profile, onRefresh }: ProfileEditorProps) { +function ProfileEditor({ agent, profile }: ProfileEditorProps) { const { toast } = useToast(); + const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); + const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); const [draft, setDraft] = useState({ ...profile }); const [savedProfile, setSavedProfile] = useState(profile); const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const syncAgentsToStore = (nextAgents: Agent[]) => { + setSettingsAgents(nextAgents); + setAgentProfiles( + nextAgents.flatMap((agentItem) => + agentItem.profiles.map((agentProfile) => ({ + id: agentProfile.id, + label: `${agentItem.name} • ${agentProfile.name}`, + agent_id: agentItem.id, + })) + ) + ); + }; const isDirty = useMemo(() => { return ( @@ -64,7 +74,17 @@ function ProfileEditor({ agent, profile, onRefresh }: ProfileEditorProps) { }); setSavedProfile(updated); setDraft(updated); - await onRefresh(); + const nextAgents = settingsAgents.map((agentItem) => + agentItem.id === agent.id + ? { + ...agentItem, + profiles: agentItem.profiles.map((profileItem) => + profileItem.id === updated.id ? updated : profileItem + ), + } + : agentItem + ); + syncAgentsToStore(nextAgents); setSaveStatus('success'); } catch (error) { setSaveStatus('error'); @@ -79,6 +99,15 @@ function ProfileEditor({ agent, profile, onRefresh }: ProfileEditorProps) { const handleDeleteProfile = async () => { try { await deleteAgentProfileAction(draft.id); + const nextAgents = settingsAgents.map((agentItem) => + agentItem.id === agent.id + ? { + ...agentItem, + profiles: agentItem.profiles.filter((profileItem) => profileItem.id !== draft.id), + } + : agentItem + ); + syncAgentsToStore(nextAgents); window.location.assign('/settings/agents'); } catch (error) { toast({ @@ -198,37 +227,22 @@ function ProfileEditor({ agent, profile, onRefresh }: ProfileEditorProps) { } export default function AgentProfilePage() { - const { toast } = useToast(); const params = useParams(); const agentParam = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId; const profileParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; const agentKey = decodeURIComponent(agentParam ?? ''); const profileId = profileParam ?? ''; - const { run: runListAgents, data, isLoading } = useRequest(listAgentsAction); - - useEffect(() => { - runListAgents().catch((error) => { - toast({ - title: 'Failed to load profile', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - }); - }, [runListAgents, toast]); + const settingsAgents = useAppStore((state) => state.settingsAgents.items); const agent = useMemo(() => { - const agents = data?.agents ?? []; - return agents.find((item) => item.name === agentKey) ?? null; - }, [agentKey, data?.agents]); + return settingsAgents.find((item) => item.name === agentKey) ?? null; + }, [agentKey, settingsAgents]); const profile = useMemo(() => { return agent?.profiles.find((item) => item.id === profileId) ?? null; }, [agent?.profiles, profileId]); if (!agent || !profile) { - if (isLoading) { - return null; - } return ( @@ -246,7 +260,6 @@ export default function AgentProfilePage() { key={profile.id} agent={agent} profile={profile} - onRefresh={runListAgents} /> ); } diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 09a9d1ee46..735c8ec615 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -1,16 +1,13 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import Link from 'next/link'; import { IconAlertTriangle, IconSettings } from '@tabler/icons-react'; import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; import { Card, CardContent } from '@kandev/ui/card'; import { Separator } from '@kandev/ui/separator'; -import { useToast } from '@/components/toast-provider'; -import { useRequest } from '@/lib/http/use-request'; -import { listAgentDiscoveryAction, listAgentsAction } from '@/app/actions/agents'; -import type { Agent, AgentDiscovery } from '@/lib/types/http'; +import { useAppStore } from '@/components/state-provider'; const AGENT_LABELS: Record = { claude: 'Claude', @@ -21,26 +18,8 @@ const AGENT_LABELS: Record = { }; export default function AgentsSettingsPage() { - const { toast } = useToast(); - const [discoveryAgents, setDiscoveryAgents] = useState([]); - const [savedAgents, setSavedAgents] = useState([]); - const { run: runListDiscovery } = useRequest(listAgentDiscoveryAction); - const { run: runListAgents } = useRequest(listAgentsAction); - - useEffect(() => { - Promise.all([runListDiscovery(), runListAgents()]) - .then(([discovery, agents]) => { - setDiscoveryAgents(discovery.agents); - setSavedAgents(agents.agents); - }) - .catch((error) => { - toast({ - title: 'Failed to load agent settings', - description: error instanceof Error ? error.message : 'Request failed', - variant: 'error', - }); - }); - }, [runListAgents, runListDiscovery, toast]); + const discoveryAgents = useAppStore((state) => state.agentDiscovery.items); + const savedAgents = useAppStore((state) => state.settingsAgents.items); const installedAgents = useMemo( () => discoveryAgents.filter((agent) => agent.available), diff --git a/apps/web/app/settings/environment/[id]/page.tsx b/apps/web/app/settings/environment/[id]/page.tsx index 6e8da321c6..e5a5761274 100644 --- a/apps/web/app/settings/environment/[id]/page.tsx +++ b/apps/web/app/settings/environment/[id]/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { use, useEffect, useMemo, useState } from 'react'; +import { use, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { IconCube, IconTrash } from '@tabler/icons-react'; +import { IconTrash } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card'; import { Input } from '@kandev/ui/input'; @@ -18,10 +18,11 @@ import { DialogHeader, DialogTitle, } from '@kandev/ui/dialog'; -import { getEnvironmentAction, updateEnvironmentAction, deleteEnvironmentAction } from '@/app/actions/environments'; +import { updateEnvironmentAction, deleteEnvironmentAction } from '@/app/actions/environments'; import { getWebSocketClient } from '@/lib/ws/connection'; import type { Environment } from '@/lib/types/http'; import type { BaseDocker } from '@/lib/settings/types'; +import { useAppStore } from '@/components/state-provider'; const BASE_IMAGE_LABELS: Record = { universal: 'Universal (Ubuntu)', @@ -33,47 +34,9 @@ const BASE_IMAGE_LABELS: Record = { export default function EnvironmentEditPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const router = useRouter(); - const [environment, setEnvironment] = useState(null); - const [savedEnvironment, setSavedEnvironment] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteConfirmText, setDeleteConfirmText] = useState(''); - const [isSaving, setIsSaving] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - useEffect(() => { - getEnvironmentAction(id) - .then((data) => { - setEnvironment(data); - setSavedEnvironment(data); - }) - .catch(() => setEnvironment(null)) - .finally(() => setIsLoading(false)); - }, [id]); - - const isDirty = useMemo(() => { - if (!environment || !savedEnvironment) return false; - return ( - environment.name !== savedEnvironment.name || - environment.kind !== savedEnvironment.kind || - environment.worktree_root !== savedEnvironment.worktree_root || - environment.image_tag !== savedEnvironment.image_tag || - environment.dockerfile !== savedEnvironment.dockerfile || - JSON.stringify(environment.build_config ?? {}) !== JSON.stringify(savedEnvironment.build_config ?? {}) - ); - }, [environment, savedEnvironment]); - - if (isLoading) { - return ( -
- - -

Loading environment...

-
-
-
- ); - } + const environment = useAppStore((state) => + state.environments.items.find((item) => item.id === id) ?? null + ); if (!environment) { return ( @@ -90,15 +53,46 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id: ); } + return ; +} + +type EnvironmentEditFormProps = { + environment: Environment; +}; + +function EnvironmentEditForm({ environment }: EnvironmentEditFormProps) { + const router = useRouter(); + const [draft, setDraft] = useState({ ...environment }); + const [savedEnvironment, setSavedEnvironment] = useState({ ...environment }); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const isSystem = draft.is_system ?? false; + + const isDirty = useMemo(() => { + if (isSystem) { + return draft.worktree_root !== savedEnvironment.worktree_root; + } + return ( + draft.name !== savedEnvironment.name || + draft.kind !== savedEnvironment.kind || + draft.worktree_root !== savedEnvironment.worktree_root || + draft.image_tag !== savedEnvironment.image_tag || + draft.dockerfile !== savedEnvironment.dockerfile || + JSON.stringify(draft.build_config ?? {}) !== JSON.stringify(savedEnvironment.build_config ?? {}) + ); + }, [draft, savedEnvironment, isSystem]); + const handleDeleteEnvironment = async () => { if (deleteConfirmText !== 'delete') return; setIsDeleting(true); try { const client = getWebSocketClient(); if (client) { - await client.request('environment.delete', { id: environment.id }); + await client.request('environment.delete', { id: draft.id }); } else { - await deleteEnvironmentAction(environment.id); + await deleteEnvironmentAction(draft.id); } router.push('/settings/environments'); } finally { @@ -108,22 +102,23 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id: }; const handleSaveEnvironment = async () => { - if (!environment) return; setIsSaving(true); try { - const payload = { - name: environment.name, - kind: environment.kind, - worktree_root: environment.worktree_root ?? undefined, - image_tag: environment.image_tag ?? undefined, - dockerfile: environment.dockerfile ?? undefined, - build_config: environment.build_config ?? undefined, + const payload: Record = { + worktree_root: draft.worktree_root ?? undefined, }; + if (!isSystem) { + payload.name = draft.name; + payload.kind = draft.kind; + payload.image_tag = draft.image_tag ?? undefined; + payload.dockerfile = draft.dockerfile ?? undefined; + payload.build_config = draft.build_config ?? undefined; + } const client = getWebSocketClient(); const updated = client - ? await client.request('environment.update', { id: environment.id, ...payload }) - : await updateEnvironmentAction(environment.id, payload); - setEnvironment(updated); + ? await client.request('environment.update', { id: draft.id, ...payload }) + : await updateEnvironmentAction(draft.id, payload); + setDraft(updated); setSavedEnvironment(updated); router.push('/settings/environments'); } finally { @@ -135,12 +130,8 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id:
-

{environment.name}

-

- {environment.kind === 'local_pc' - ? 'Uses your local machine and installed agents.' - : 'Runs inside a Docker image on the selected executor.'} -

+

{draft.name}

+

Configure runtime environment settings.