feat: agents discovery

This commit is contained in:
Carlos Florêncio
2026-01-13 00:45:10 +00:00
parent bcbe09fbb7
commit 084756c921
29 changed files with 2522 additions and 469 deletions
+4
View File
@@ -869,6 +869,10 @@ 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
## 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.
### Message Flow
```
+8 -1
View File
@@ -75,7 +75,14 @@ dev:
@echo "Backend: http://localhost:8080"
@echo "Web app: http://localhost:3000"
@echo ""
@$(MAKE) -C $(BACKEND_DIR) run & cd $(APPS_DIR) && $(PNPM) --filter @kandev/web dev
@bash -c 'set -euo pipefail; \
$(MAKE) -C $(BACKEND_DIR) run & backend_pid=$$!; \
cd $(APPS_DIR); $(PNPM) --filter @kandev/web dev & web_pid=$$!; \
cleanup() { kill $$backend_pid $$web_pid 2>/dev/null || true; }; \
trap cleanup EXIT INT TERM; \
wait -n $$backend_pid $$web_pid; status=$$?; \
if [ $$status -eq 0 ]; then status=1; fi; \
exit $$status'
.PHONY: dev-backend
dev-backend:
+19
View File
@@ -29,10 +29,14 @@ import (
// Agent Manager packages
agentcontroller "github.com/kandev/kandev/internal/agent/controller"
"github.com/kandev/kandev/internal/agent/credentials"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/docker"
agenthandlers "github.com/kandev/kandev/internal/agent/handlers"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/agent/worktree"
// Orchestrator packages
@@ -130,6 +134,18 @@ func main() {
defer taskRepo.Close()
log.Info("SQLite database initialized", zap.String("db_path", dbPath))
agentSettingsRepo, err := settingsstore.NewSQLiteRepository(dbPath)
if err != nil {
log.Fatal("Failed to initialize agent settings store", zap.Error(err), zap.String("db_path", dbPath))
}
defer agentSettingsRepo.Close()
discoveryRegistry, err := discovery.LoadRegistry()
if err != nil {
log.Fatal("Failed to load agent discovery config", zap.Error(err))
}
agentSettingsController := agentsettingscontroller.NewController(agentSettingsRepo, discoveryRegistry, log)
taskSvc := taskservice.NewService(
taskRepo,
eventBus,
@@ -514,6 +530,9 @@ func main() {
taskhandlers.RegisterCommentRoutes(router, gateway.Dispatcher, taskSvc, &orchestratorAdapter{svc: orchestratorSvc}, log)
log.Info("Registered Task Service handlers (HTTP + WebSocket)")
agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log)
log.Info("Registered Agent Settings handlers (HTTP)")
// Health check (simple HTTP for load balancers/monitoring)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
@@ -0,0 +1,99 @@
{
"agents": [
{
"name": "claude",
"supports_mcp": false,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": ["~/.claude.json"],
"windows": ["~/.claude.json"],
"macos": ["~/.claude.json"]
}
},
{
"name": "gemini",
"supports_mcp": false,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": ["~/.gemini/oauth_creds.json"],
"windows": ["~/.gemini/oauth_creds.json"],
"macos": ["~/.gemini/oauth_creds.json"]
}
},
{
"name": "codex",
"supports_mcp": false,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": ["~/.codex/auth.json"],
"windows": ["~/.codex/auth.json"],
"macos": ["~/.codex/auth.json"]
}
},
{
"name": "auggie",
"supports_mcp": false,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": ["~/.augment/.auggie.json"],
"windows": ["~/.augment/.auggie.json"],
"macos": ["~/.augment/.auggie.json"]
}
},
{
"name": "opencode",
"supports_mcp": false,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": [
"$XDG_CONFIG_HOME/opencode/opencode.json",
"~/.config/opencode/opencode.json",
"$XDG_CONFIG_HOME/opencode",
"~/.config/opencode"
],
"windows": [
"~/AppData/Roaming/opencode/opencode.json",
"~/AppData/Roaming/opencode"
],
"macos": [
"~/Library/Application Support/opencode/opencode.json",
"~/Library/Application Support/opencode"
]
}
},
{
"name": "copilot",
"supports_mcp": true,
"mcp_config_path": {
"linux": ["~/.copilot/mcp-config.json"],
"windows": ["~/.copilot/mcp-config.json"],
"macos": ["~/.copilot/mcp-config.json"]
},
"installation_path": {
"linux": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"],
"windows": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"],
"macos": ["~/.copilot/mcp-config.json", "~/.copilot/config.json"]
}
}
]
}
@@ -0,0 +1,161 @@
package discovery
import (
"context"
"embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
//go:embed agents.json
var agentsFS embed.FS
type OSPaths struct {
Linux []string `json:"linux"`
Windows []string `json:"windows"`
MacOS []string `json:"macos"`
}
func (p OSPaths) ForOS(goos string) []string {
switch goos {
case "windows":
return p.Windows
case "darwin":
return p.MacOS
default:
return p.Linux
}
}
type KnownAgent struct {
Name string `json:"name"`
SupportsMCP bool `json:"supports_mcp"`
MCPConfigPath OSPaths `json:"mcp_config_path"`
InstallationPath OSPaths `json:"installation_path"`
}
type Config struct {
Agents []KnownAgent `json:"agents"`
}
type Availability struct {
Name string `json:"name"`
SupportsMCP bool `json:"supports_mcp"`
MCPConfigPath string `json:"mcp_config_path,omitempty"`
InstallationPaths []string `json:"installation_paths,omitempty"`
Available bool `json:"available"`
MatchedPath string `json:"matched_path,omitempty"`
}
type Adapter interface {
Detect(ctx context.Context) (Availability, error)
}
type Registry struct {
adapters []Adapter
}
func LoadRegistry() (*Registry, error) {
data, err := agentsFS.ReadFile("agents.json")
if err != nil {
return nil, fmt.Errorf("read agents config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse agents config: %w", err)
}
adapters := make([]Adapter, 0, len(cfg.Agents))
for _, agent := range cfg.Agents {
adapters = append(adapters, NewFilePresenceAdapter(agent))
}
return &Registry{adapters: adapters}, nil
}
func (r *Registry) Detect(ctx context.Context) ([]Availability, error) {
results := make([]Availability, 0, len(r.adapters))
for _, adapter := range r.adapters {
availability, err := adapter.Detect(ctx)
if err != nil {
return nil, err
}
results = append(results, availability)
}
return results, nil
}
type FilePresenceAdapter struct {
definition KnownAgent
}
func NewFilePresenceAdapter(def KnownAgent) *FilePresenceAdapter {
return &FilePresenceAdapter{definition: def}
}
func (a *FilePresenceAdapter) Detect(ctx context.Context) (Availability, error) {
_ = ctx
paths := resolvePaths(a.definition.InstallationPath.ForOS(runtime.GOOS))
mcpPaths := resolvePaths(a.definition.MCPConfigPath.ForOS(runtime.GOOS))
available, matched := anyPathExists(paths)
mcpPath := ""
if len(mcpPaths) > 0 {
mcpPath = mcpPaths[0]
}
return Availability{
Name: a.definition.Name,
SupportsMCP: a.definition.SupportsMCP,
MCPConfigPath: mcpPath,
InstallationPaths: paths,
Available: available,
MatchedPath: matched,
}, nil
}
func resolvePaths(paths []string) []string {
resolved := make([]string, 0, len(paths))
for _, rawPath := range paths {
expanded := expandPath(rawPath)
if expanded == "" {
continue
}
resolved = append(resolved, expanded)
}
return resolved
}
func expandPath(path string) string {
if path == "" {
return ""
}
if strings.Contains(path, "$XDG_CONFIG_HOME") || strings.Contains(path, "${XDG_CONFIG_HOME}") {
if _, ok := os.LookupEnv("XDG_CONFIG_HOME"); !ok {
return ""
}
}
expanded := os.ExpandEnv(path)
if strings.HasPrefix(expanded, "~") {
home, err := os.UserHomeDir()
if err == nil {
expanded = filepath.Join(home, strings.TrimPrefix(expanded, "~"))
}
}
if expanded == "" {
return ""
}
return filepath.Clean(filepath.FromSlash(expanded))
}
func anyPathExists(paths []string) (bool, string) {
for _, candidate := range paths {
if candidate == "" {
continue
}
if _, err := os.Stat(candidate); err == nil {
return true, candidate
}
}
return false, ""
}
@@ -0,0 +1,300 @@
package controller
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
var (
ErrAgentNotFound = errors.New("agent not found")
ErrAgentProfileNotFound = errors.New("agent profile not found")
)
type Controller struct {
repo store.Repository
discovery *discovery.Registry
logger *logger.Logger
}
func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, log *logger.Logger) *Controller {
return &Controller{
repo: repo,
discovery: discoveryRegistry,
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
}
}
func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) {
results, err := c.discovery.Detect(ctx)
if err != nil {
return nil, err
}
payload := make([]dto.AgentDiscoveryDTO, 0, len(results))
for _, result := range results {
payload = append(payload, dto.AgentDiscoveryDTO{
Name: result.Name,
SupportsMCP: result.SupportsMCP,
MCPConfigPath: result.MCPConfigPath,
InstallationPaths: result.InstallationPaths,
Available: result.Available,
MatchedPath: result.MatchedPath,
})
}
return &dto.ListDiscoveryResponse{Agents: payload, Total: len(payload)}, nil
}
func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, error) {
agents, err := c.repo.ListAgents(ctx)
if err != nil {
return nil, err
}
payload := make([]dto.AgentDTO, 0, len(agents))
for _, agent := range agents {
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return nil, err
}
payload = append(payload, toAgentDTO(agent, profiles))
}
return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil
}
func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) {
agent, err := c.repo.GetAgent(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAgentNotFound
}
return nil, err
}
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return nil, err
}
result := toAgentDTO(agent, profiles)
return &result, nil
}
type CreateAgentRequest struct {
Name string
WorkspaceID *string
Profiles []CreateAgentProfileRequest
}
type CreateAgentProfileRequest struct {
Name string
Model string
AutoApprove bool
DangerouslySkipPermissions bool
Plan string
}
func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) {
if req.Name == "" {
return nil, fmt.Errorf("name is required")
}
existing, err := c.repo.GetAgentByName(ctx, req.Name)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if err == nil && existing != nil {
return nil, fmt.Errorf("agent already configured: %s", req.Name)
}
discoveryResults, err := c.discovery.Detect(ctx)
if err != nil {
return nil, err
}
var matched *discovery.Availability
for _, result := range discoveryResults {
if result.Name == req.Name {
matched = &result
break
}
}
if matched == nil {
return nil, fmt.Errorf("unknown agent: %s", req.Name)
}
if !matched.Available {
return nil, fmt.Errorf("agent not installed: %s", req.Name)
}
agent := &models.Agent{
Name: matched.Name,
WorkspaceID: req.WorkspaceID,
SupportsMCP: matched.SupportsMCP,
MCPConfigPath: matched.MCPConfigPath,
}
if err := c.repo.CreateAgent(ctx, agent); err != nil {
return nil, err
}
profiles := make([]*models.AgentProfile, 0, len(req.Profiles))
for _, profileReq := range req.Profiles {
profile := &models.AgentProfile{
AgentID: agent.ID,
Name: profileReq.Name,
Model: profileReq.Model,
AutoApprove: profileReq.AutoApprove,
DangerouslySkipPermissions: profileReq.DangerouslySkipPermissions,
Plan: profileReq.Plan,
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
return nil, err
}
profiles = append(profiles, profile)
}
result := toAgentDTO(agent, profiles)
return &result, nil
}
type UpdateAgentRequest struct {
ID string
WorkspaceID *string
SupportsMCP *bool
MCPConfigPath *string
}
func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) {
agent, err := c.repo.GetAgent(ctx, req.ID)
if err != nil {
return nil, ErrAgentNotFound
}
if req.WorkspaceID != nil {
agent.WorkspaceID = req.WorkspaceID
}
if req.SupportsMCP != nil {
agent.SupportsMCP = *req.SupportsMCP
}
if req.MCPConfigPath != nil {
agent.MCPConfigPath = *req.MCPConfigPath
}
if err := c.repo.UpdateAgent(ctx, agent); err != nil {
return nil, err
}
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return nil, err
}
result := toAgentDTO(agent, profiles)
return &result, nil
}
func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
if err := c.repo.DeleteAgent(ctx, id); err != nil {
if strings.Contains(err.Error(), "agent not found") {
return ErrAgentNotFound
}
return err
}
return nil
}
type CreateProfileRequest struct {
AgentID string
Name string
Model string
AutoApprove bool
DangerouslySkipPermissions bool
Plan string
}
func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) {
profile := &models.AgentProfile{
AgentID: req.AgentID,
Name: req.Name,
Model: req.Model,
AutoApprove: req.AutoApprove,
DangerouslySkipPermissions: req.DangerouslySkipPermissions,
Plan: req.Plan,
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
return nil, err
}
result := toProfileDTO(profile)
return &result, nil
}
type UpdateProfileRequest struct {
ID string
Name *string
Model *string
AutoApprove *bool
DangerouslySkipPermissions *bool
Plan *string
}
func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) {
profile, err := c.repo.GetAgentProfile(ctx, req.ID)
if err != nil {
return nil, ErrAgentProfileNotFound
}
if req.Name != nil {
profile.Name = *req.Name
}
if req.Model != nil {
profile.Model = *req.Model
}
if req.AutoApprove != nil {
profile.AutoApprove = *req.AutoApprove
}
if req.DangerouslySkipPermissions != nil {
profile.DangerouslySkipPermissions = *req.DangerouslySkipPermissions
}
if req.Plan != nil {
profile.Plan = *req.Plan
}
if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil {
return nil, err
}
result := toProfileDTO(profile)
return &result, nil
}
func (c *Controller) DeleteProfile(ctx context.Context, id string) error {
if err := c.repo.DeleteAgentProfile(ctx, id); err != nil {
if strings.Contains(err.Error(), "agent profile not found") {
return ErrAgentProfileNotFound
}
return err
}
return nil
}
func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO {
profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles))
for _, profile := range profiles {
profileDTOs = append(profileDTOs, toProfileDTO(profile))
}
return dto.AgentDTO{
ID: agent.ID,
Name: agent.Name,
WorkspaceID: agent.WorkspaceID,
SupportsMCP: agent.SupportsMCP,
MCPConfigPath: agent.MCPConfigPath,
Profiles: profileDTOs,
CreatedAt: agent.CreatedAt,
UpdatedAt: agent.UpdatedAt,
}
}
func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO {
return dto.AgentProfileDTO{
ID: profile.ID,
AgentID: profile.AgentID,
Name: profile.Name,
Model: profile.Model,
AutoApprove: profile.AutoApprove,
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
Plan: profile.Plan,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}
}
@@ -0,0 +1,45 @@
package dto
import "time"
type AgentProfileDTO struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
Name string `json:"name"`
Model string `json:"model"`
AutoApprove bool `json:"auto_approve"`
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
Plan string `json:"plan"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AgentDTO struct {
ID string `json:"id"`
Name string `json:"name"`
WorkspaceID *string `json:"workspace_id,omitempty"`
SupportsMCP bool `json:"supports_mcp"`
MCPConfigPath string `json:"mcp_config_path,omitempty"`
Profiles []AgentProfileDTO `json:"profiles"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListAgentsResponse struct {
Agents []AgentDTO `json:"agents"`
Total int `json:"total"`
}
type AgentDiscoveryDTO struct {
Name string `json:"name"`
SupportsMCP bool `json:"supports_mcp"`
MCPConfigPath string `json:"mcp_config_path,omitempty"`
InstallationPaths []string `json:"installation_paths,omitempty"`
Available bool `json:"available"`
MatchedPath string `json:"matched_path,omitempty"`
}
type ListDiscoveryResponse struct {
Agents []AgentDiscoveryDTO `json:"agents"`
Total int `json:"total"`
}
@@ -0,0 +1,269 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agent/settings/controller"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
type Handlers struct {
controller *controller.Controller
hub Broadcaster
logger *logger.Logger
}
type Broadcaster interface {
Broadcast(msg *ws.Message)
}
func NewHandlers(ctrl *controller.Controller, hub Broadcaster, log *logger.Logger) *Handlers {
return &Handlers{
controller: ctrl,
hub: hub,
logger: log.WithFields(zap.String("component", "agent-settings-handlers")),
}
}
func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, hub Broadcaster, log *logger.Logger) {
handlers := NewHandlers(ctrl, hub, log)
handlers.registerHTTP(router)
}
func (h *Handlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/agents/discovery", h.httpDiscoverAgents)
api.GET("/agents", h.httpListAgents)
api.POST("/agents", h.httpCreateAgent)
api.GET("/agents/:id", h.httpGetAgent)
api.PATCH("/agents/:id", h.httpUpdateAgent)
api.DELETE("/agents/:id", h.httpDeleteAgent)
api.POST("/agents/:id/profiles", h.httpCreateProfile)
api.PATCH("/agent-profiles/:id", h.httpUpdateProfile)
api.DELETE("/agent-profiles/:id", h.httpDeleteProfile)
}
func (h *Handlers) httpDiscoverAgents(c *gin.Context) {
resp, err := h.controller.ListDiscovery(c.Request.Context())
if err != nil {
h.logger.Error("failed to discover agents", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to discover agents"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpListAgents(c *gin.Context) {
resp, err := h.controller.ListAgents(c.Request.Context())
if err != nil {
h.logger.Error("failed to list agents", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agents"})
return
}
c.JSON(http.StatusOK, resp)
}
type createAgentRequest struct {
Name string `json:"name"`
WorkspaceID *string `json:"workspace_id,omitempty"`
Profiles []createAgentProfileRequest `json:"profiles,omitempty"`
}
type createAgentProfileRequest struct {
Name string `json:"name"`
Model string `json:"model"`
AutoApprove bool `json:"auto_approve"`
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
Plan string `json:"plan"`
}
func (h *Handlers) httpCreateAgent(c *gin.Context) {
var body createAgentRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
profiles := make([]controller.CreateAgentProfileRequest, 0, len(body.Profiles))
for _, profile := range body.Profiles {
profiles = append(profiles, controller.CreateAgentProfileRequest{
Name: profile.Name,
Model: profile.Model,
AutoApprove: profile.AutoApprove,
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
Plan: profile.Plan,
})
}
resp, err := h.controller.CreateAgent(c.Request.Context(), controller.CreateAgentRequest{
Name: body.Name,
WorkspaceID: body.WorkspaceID,
Profiles: profiles,
})
if err != nil {
h.logger.Error("failed to create agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpGetAgent(c *gin.Context) {
resp, err := h.controller.GetAgent(c.Request.Context(), c.Param("id"))
if err != nil {
if err == controller.ErrAgentNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
h.logger.Error("failed to get agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agent"})
return
}
c.JSON(http.StatusOK, resp)
}
type updateAgentRequest struct {
WorkspaceID *string `json:"workspace_id,omitempty"`
SupportsMCP *bool `json:"supports_mcp,omitempty"`
MCPConfigPath *string `json:"mcp_config_path,omitempty"`
}
func (h *Handlers) httpUpdateAgent(c *gin.Context) {
var body updateAgentRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.UpdateAgent(c.Request.Context(), controller.UpdateAgentRequest{
ID: c.Param("id"),
WorkspaceID: body.WorkspaceID,
SupportsMCP: body.SupportsMCP,
MCPConfigPath: body.MCPConfigPath,
})
if err != nil {
if err == controller.ErrAgentNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
h.logger.Error("failed to update agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update agent"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpDeleteAgent(c *gin.Context) {
if err := h.controller.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
if err == controller.ErrAgentNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
h.logger.Error("failed to delete agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
type createProfileRequest struct {
Name string `json:"name"`
Model string `json:"model"`
AutoApprove bool `json:"auto_approve"`
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
Plan string `json:"plan"`
}
func (h *Handlers) httpCreateProfile(c *gin.Context) {
var body createProfileRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequest{
AgentID: c.Param("id"),
Name: body.Name,
Model: body.Model,
AutoApprove: body.AutoApprove,
DangerouslySkipPermissions: body.DangerouslySkipPermissions,
Plan: body.Plan,
})
if err != nil {
h.logger.Error("failed to create profile", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile"})
return
}
if h.hub != nil {
notification, _ := ws.NewNotification(ws.ActionAgentProfileCreated, gin.H{
"profile_id": resp.ID,
"agent_id": resp.AgentID,
})
h.hub.Broadcast(notification)
}
c.JSON(http.StatusOK, resp)
}
type updateProfileRequest struct {
Name *string `json:"name,omitempty"`
Model *string `json:"model,omitempty"`
AutoApprove *bool `json:"auto_approve,omitempty"`
DangerouslySkipPermissions *bool `json:"dangerously_skip_permissions,omitempty"`
Plan *string `json:"plan,omitempty"`
}
func (h *Handlers) httpUpdateProfile(c *gin.Context) {
var body updateProfileRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequest{
ID: c.Param("id"),
Name: body.Name,
Model: body.Model,
AutoApprove: body.AutoApprove,
DangerouslySkipPermissions: body.DangerouslySkipPermissions,
Plan: body.Plan,
})
if err != nil {
if err == controller.ErrAgentProfileNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
return
}
h.logger.Error("failed to update profile", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"})
return
}
if h.hub != nil {
notification, _ := ws.NewNotification(ws.ActionAgentProfileUpdated, gin.H{
"profile_id": resp.ID,
"agent_id": resp.AgentID,
})
h.hub.Broadcast(notification)
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpDeleteProfile(c *gin.Context) {
if err := h.controller.DeleteProfile(c.Request.Context(), c.Param("id")); err != nil {
if err == controller.ErrAgentProfileNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
return
}
h.logger.Error("failed to delete profile", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile"})
return
}
if h.hub != nil {
notification, _ := ws.NewNotification(ws.ActionAgentProfileDeleted, gin.H{
"profile_id": c.Param("id"),
})
h.hub.Broadcast(notification)
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -0,0 +1,25 @@
package models
import "time"
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
WorkspaceID *string `json:"workspace_id,omitempty"`
SupportsMCP bool `json:"supports_mcp"`
MCPConfigPath string `json:"mcp_config_path,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AgentProfile struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
Name string `json:"name"`
Model string `json:"model"`
AutoApprove bool `json:"auto_approve"`
DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"`
Plan string `json:"plan"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,317 @@
package store
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/agent/settings/models"
)
type SQLiteRepository struct {
db *sql.DB
}
var _ Repository = (*SQLiteRepository)(nil)
func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) {
normalizedPath := normalizeSQLitePath(dbPath)
if err := ensureSQLiteDir(normalizedPath); err != nil {
return nil, fmt.Errorf("failed to prepare database path: %w", err)
}
if err := ensureSQLiteFile(normalizedPath); err != nil {
return nil, fmt.Errorf("failed to create database file: %w", err)
}
dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
repo := &SQLiteRepository{db: db}
if err := repo.initSchema(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to initialize schema: %w", err)
}
return repo, nil
}
func ensureSQLiteDir(dbPath string) error {
dir := filepath.Dir(dbPath)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
func ensureSQLiteFile(dbPath string) error {
file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644)
if err != nil {
return err
}
return file.Close()
}
func normalizeSQLitePath(dbPath string) string {
if dbPath == "" {
return dbPath
}
abs, err := filepath.Abs(dbPath)
if err != nil {
return dbPath
}
return abs
}
func (r *SQLiteRepository) initSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
workspace_id TEXT DEFAULT NULL,
supports_mcp INTEGER NOT NULL DEFAULT 0,
mcp_config_path TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_profiles (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
name TEXT NOT NULL,
model TEXT NOT NULL,
auto_approve INTEGER NOT NULL DEFAULT 0,
dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0,
plan TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
DROP INDEX IF EXISTS idx_agents_name;
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_name ON agents(name);
CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id);
`
_, err := r.db.Exec(schema)
return err
}
func (r *SQLiteRepository) Close() error {
return r.db.Close()
}
func (r *SQLiteRepository) CreateAgent(ctx context.Context, agent *models.Agent) error {
if agent.ID == "" {
agent.ID = uuid.New().String()
}
now := time.Now().UTC()
agent.CreatedAt = now
agent.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, agent.ID, agent.Name, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
return err
}
func (r *SQLiteRepository) GetAgent(ctx context.Context, id string) (*models.Agent, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
FROM agents WHERE id = ?
`, id)
return scanAgent(row)
}
func (r *SQLiteRepository) GetAgentByName(ctx context.Context, name string) (*models.Agent, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
FROM agents WHERE name = ?
`, name)
return scanAgent(row)
}
func (r *SQLiteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error {
agent.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ?
WHERE id = ?
`, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent not found: %s", agent.ID)
}
return nil
}
func (r *SQLiteRepository) DeleteAgent(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM agents WHERE id = ?`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent not found: %s", id)
}
return nil
}
func (r *SQLiteRepository) ListAgents(ctx context.Context) ([]*models.Agent, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at
FROM agents ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []*models.Agent
for rows.Next() {
agent, err := scanAgent(rows)
if err != nil {
return nil, err
}
result = append(result, agent)
}
return result, rows.Err()
}
func (r *SQLiteRepository) CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
if profile.ID == "" {
profile.ID = uuid.New().String()
}
now := time.Now().UTC()
profile.CreatedAt = now
profile.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO agent_profiles (id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, profile.ID, profile.AgentID, profile.Name, profile.Model, boolToInt(profile.AutoApprove),
boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.CreatedAt, profile.UpdatedAt)
return err
}
func (r *SQLiteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
profile.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE agent_profiles
SET name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, plan = ?, updated_at = ?
WHERE id = ?
`, profile.Name, profile.Model, boolToInt(profile.AutoApprove),
boolToInt(profile.DangerouslySkipPermissions), profile.Plan, profile.UpdatedAt, profile.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent profile not found: %s", profile.ID)
}
return nil
}
func (r *SQLiteRepository) DeleteAgentProfile(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM agent_profiles WHERE id = ?`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("agent profile not found: %s", id)
}
return nil
}
func (r *SQLiteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at
FROM agent_profiles WHERE id = ?
`, id)
return scanAgentProfile(row)
}
func (r *SQLiteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, agent_id, name, model, auto_approve, dangerously_skip_permissions, plan, created_at, updated_at
FROM agent_profiles WHERE agent_id = ? ORDER BY created_at DESC
`, agentID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []*models.AgentProfile
for rows.Next() {
profile, err := scanAgentProfile(rows)
if err != nil {
return nil, err
}
result = append(result, profile)
}
return result, rows.Err()
}
func scanAgent(scanner interface {
Scan(dest ...interface{}) error
}) (*models.Agent, error) {
agent := &models.Agent{}
var supportsMCP int
var workspaceID sql.NullString
if err := scanner.Scan(
&agent.ID,
&agent.Name,
&workspaceID,
&supportsMCP,
&agent.MCPConfigPath,
&agent.CreatedAt,
&agent.UpdatedAt,
); err != nil {
return nil, err
}
if workspaceID.Valid {
agent.WorkspaceID = &workspaceID.String
}
agent.SupportsMCP = supportsMCP == 1
return agent, nil
}
func scanAgentProfile(scanner interface {
Scan(dest ...interface{}) error
}) (*models.AgentProfile, error) {
profile := &models.AgentProfile{}
var autoApprove int
var skipPermissions int
if err := scanner.Scan(
&profile.ID,
&profile.AgentID,
&profile.Name,
&profile.Model,
&autoApprove,
&skipPermissions,
&profile.Plan,
&profile.CreatedAt,
&profile.UpdatedAt,
); err != nil {
return nil, err
}
profile.AutoApprove = autoApprove == 1
profile.DangerouslySkipPermissions = skipPermissions == 1
return profile, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
@@ -0,0 +1,24 @@
package store
import (
"context"
"github.com/kandev/kandev/internal/agent/settings/models"
)
type Repository interface {
CreateAgent(ctx context.Context, agent *models.Agent) error
GetAgent(ctx context.Context, id string) (*models.Agent, error)
GetAgentByName(ctx context.Context, name string) (*models.Agent, error)
UpdateAgent(ctx context.Context, agent *models.Agent) error
DeleteAgent(ctx context.Context, id string) error
ListAgents(ctx context.Context) ([]*models.Agent, error)
CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error
UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error
DeleteAgentProfile(ctx context.Context, id string) error
GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error)
ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error)
Close() error
}
+4
View File
@@ -131,6 +131,10 @@ const (
ActionEnvironmentUpdated = "environment.updated"
ActionEnvironmentDeleted = "environment.deleted"
ActionAgentProfileDeleted = "agent.profile.deleted"
ActionAgentProfileCreated = "agent.profile.created"
ActionAgentProfileUpdated = "agent.profile.updated"
// Permission request actions (agent -> user -> agent)
ActionPermissionRequested = "permission.requested" // Agent requesting permission
ActionPermissionRespond = "permission.respond" // User responding to permission request
+102
View File
@@ -0,0 +1,102 @@
'use server';
import { getBackendConfig } from '@/lib/config';
import type {
Agent,
AgentProfile,
ListAgentsResponse,
ListAgentDiscoveryResponse,
} from '@/lib/types/http';
const { apiBaseUrl } = getBackendConfig();
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export async function listAgentDiscoveryAction(): Promise<ListAgentDiscoveryResponse> {
return fetchJson<ListAgentDiscoveryResponse>(`${apiBaseUrl}/api/v1/agents/discovery`);
}
export async function listAgentsAction(): Promise<ListAgentsResponse> {
return fetchJson<ListAgentsResponse>(`${apiBaseUrl}/api/v1/agents`);
}
export async function createAgentAction(payload: {
name: string;
workspace_id?: string | null;
profiles?: Array<{
name: string;
model: string;
auto_approve: boolean;
dangerously_skip_permissions: boolean;
plan: string;
}>;
}): Promise<Agent> {
return fetchJson<Agent>(`${apiBaseUrl}/api/v1/agents`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateAgentAction(
id: string,
payload: { workspace_id?: string | null; supports_mcp?: boolean; mcp_config_path?: string | null }
): Promise<Agent> {
return fetchJson<Agent>(`${apiBaseUrl}/api/v1/agents/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function deleteAgentAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/agents/${id}`, { method: 'DELETE' });
}
export async function createAgentProfileAction(
agentId: string,
payload: {
name: string;
model: string;
auto_approve: boolean;
dangerously_skip_permissions: boolean;
plan: string;
}
): Promise<AgentProfile> {
return fetchJson<AgentProfile>(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateAgentProfileAction(
id: string,
payload: Partial<Pick<AgentProfile, 'name' | 'model' | 'auto_approve' | 'dangerously_skip_permissions' | 'plan'>>
): Promise<AgentProfile> {
return fetchJson<AgentProfile>(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function deleteAgentProfileAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { method: 'DELETE' });
}
+14 -240
View File
@@ -1,251 +1,25 @@
'use client';
import { use, useState } from 'react';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { IconTrash } from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import { Switch } from '@kandev/ui/switch';
import { Slider } from '@kandev/ui/slider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import type { AgentProfile, AgentType } from '@/lib/settings/types';
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
'claude-code': [
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
],
'codex': [
{ value: 'codex-v2', label: 'Codex v2' },
{ value: 'codex-v1', label: 'Codex v1' },
],
'auggie': [
{ value: 'auggie-v1', label: 'Auggie v1' },
{ value: 'auggie-lite', label: 'Auggie Lite' },
],
};
export default function AgentEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
export default function AgentEditPage() {
const router = useRouter();
const [agent, setAgent] = useState<AgentProfile | undefined>(
SETTINGS_DATA.agents.find((a) => a.id === id)
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
if (!agent) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Agent not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/agents')}>
Go to Agents
</Button>
</CardContent>
</Card>
</div>
);
}
const handleDeleteAgent = () => {
if (deleteConfirmText === 'delete') {
router.push('/settings/agents');
setDeleteDialogOpen(false);
}
};
useEffect(() => {
router.replace('/settings/agents');
}, [router]);
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{agent.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure agent behavior and capabilities
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle>Agent Name</CardTitle>
</CardHeader>
<CardContent>
<Input
value={agent.name}
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
placeholder="Code Assistant"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Agent Configuration</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Agent Type</Label>
<Select
value={agent.agent}
onValueChange={(value) => {
const agentType = value as AgentType;
setAgent({
...agent,
agent: agentType,
model: MODEL_OPTIONS[agentType][0].value
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude-code">Claude Code</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="auggie">Auggie</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Select
value={agent.model}
onValueChange={(value) => setAgent({ ...agent, model: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_OPTIONS[agent.agent].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Temperature</Label>
<p className="text-sm text-muted-foreground">
Controls randomness in responses (0 = deterministic, 1 = creative)
</p>
</div>
<span className="text-sm font-medium">{agent.temperature.toFixed(1)}</span>
</div>
<Slider
value={[agent.temperature]}
onValueChange={([value]) => setAgent({ ...agent, temperature: value })}
min={0}
max={1}
step={0.1}
className="w-full"
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Auto-approve Actions</Label>
<p className="text-sm text-muted-foreground">
Automatically execute tool calls without confirmation
</p>
</div>
<Switch
checked={agent.autoApprove}
onCheckedChange={(checked) => setAgent({ ...agent, autoApprove: checked })}
/>
</div>
</CardContent>
</Card>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently delete this agent
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this agent</p>
<p className="text-sm text-muted-foreground">
Once deleted, all configuration will be permanently removed
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Agent
</Button>
</div>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Agent</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the agent &quot;
{agent.name}&quot; and all its data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteAgent}
disabled={deleteConfirmText !== 'delete'}
>
Delete Agent
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Manage agents from the main Agents page.</p>
<Button className="mt-4" onClick={() => router.push('/settings/agents')}>
Go to Agents
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,432 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { IconPlus } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Separator } from '@kandev/ui/separator';
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 {
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';
type DraftAgent = Agent & { isNew?: boolean };
type DraftProfile = AgentProfile & { isNew?: boolean };
type AgentSetupFormProps = {
initialAgent: DraftAgent;
savedAgent: Agent | null;
discoveryAgent: AgentDiscovery | undefined;
refreshAgents: () => Promise<unknown>;
onToastError: (error: unknown) => void;
};
const AGENT_LABELS: Record<string, string> = {
claude: 'Claude',
gemini: 'Gemini',
codex: 'Codex',
opencode: 'OpenCode',
copilot: 'Copilot',
};
const createDraftProfile = (agentId: string): DraftProfile => ({
id: `draft-${generateUUID()}`,
agent_id: agentId,
name: '',
model: '',
auto_approve: false,
dangerously_skip_permissions: false,
plan: '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
isNew: true,
});
const cloneAgent = (agent: Agent): DraftAgent => ({
...agent,
workspace_id: agent.workspace_id ?? null,
mcp_config_path: agent.mcp_config_path ?? '',
profiles: agent.profiles.map((profile) => ({ ...profile })),
});
const ensureProfiles = (agent: DraftAgent): DraftAgent => {
if (agent.profiles.length > 0) {
return agent;
}
return {
...agent,
profiles: [createDraftProfile(agent.id)],
};
};
function AgentSetupForm({
initialAgent,
savedAgent,
discoveryAgent,
refreshAgents,
onToastError,
}: AgentSetupFormProps) {
const router = useRouter();
const [draftAgent, setDraftAgent] = useState<DraftAgent>(initialAgent);
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const isProfileDirty = (draft: DraftProfile, saved?: AgentProfile) => {
if (!saved) return true;
return (
draft.name !== saved.name ||
draft.model !== saved.model ||
draft.auto_approve !== saved.auto_approve ||
draft.dangerously_skip_permissions !== saved.dangerously_skip_permissions ||
draft.plan !== saved.plan
);
};
const isAgentDirty = useMemo(() => {
if (!draftAgent) return false;
if (!savedAgent) return true;
if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) return true;
if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) return true;
if (draftAgent.profiles.length !== savedAgent.profiles.length) return true;
const savedProfiles = new Map(savedAgent.profiles.map((profile) => [profile.id, profile]));
for (const profile of draftAgent.profiles) {
const savedProfile = savedProfiles.get(profile.id);
if (!savedProfile || isProfileDirty(profile, savedProfile)) return true;
}
return false;
}, [draftAgent, savedAgent]);
const handleAddProfile = () => {
setDraftAgent((current) => ({
...current,
profiles: [...current.profiles, createDraftProfile(current.id)],
}));
};
const handleRemoveProfile = (profileId: string) => {
setDraftAgent((current) => {
const remaining = current.profiles.filter((profile) => profile.id !== profileId);
return {
...current,
profiles: remaining.length > 0 ? remaining : [createDraftProfile(current.id)],
};
});
};
const handleProfileChange = (profileId: string, patch: Partial<DraftProfile>) => {
setDraftAgent((current) => ({
...current,
profiles: current.profiles.map((profile) =>
profile.id === profileId ? { ...profile, ...patch } : profile
),
}));
};
const handleAgentFieldChange = (patch: Partial<DraftAgent>) => {
setDraftAgent((current) => ({ ...current, ...patch }));
};
const handleSave = async () => {
setSaveStatus('loading');
try {
if (!savedAgent) {
const created = await createAgentAction({
name: draftAgent.name,
workspace_id: draftAgent.workspace_id,
profiles: draftAgent.profiles.map((profile) => ({
name: profile.name,
model: profile.model,
auto_approve: profile.auto_approve,
dangerously_skip_permissions: profile.dangerously_skip_permissions,
plan: profile.plan,
})),
});
if ((draftAgent.mcp_config_path ?? '') !== (created.mcp_config_path ?? '')) {
await updateAgentAction(created.id, { mcp_config_path: draftAgent.mcp_config_path ?? '' });
}
await refreshAgents();
router.replace(`/settings/agents/${encodeURIComponent(created.name)}`);
} else {
const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {};
if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) {
agentPatch.workspace_id = draftAgent.workspace_id ?? null;
}
if ((draftAgent.mcp_config_path ?? '') !== (savedAgent.mcp_config_path ?? '')) {
agentPatch.mcp_config_path = draftAgent.mcp_config_path ?? '';
}
if (Object.keys(agentPatch).length > 0) {
await updateAgentAction(savedAgent.id, agentPatch);
}
const savedProfilesById = new Map(savedAgent.profiles.map((profile) => [profile.id, profile]));
for (const profile of draftAgent.profiles) {
const savedProfile = savedProfilesById.get(profile.id);
if (!savedProfile) {
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,
});
continue;
}
if (isProfileDirty(profile, savedProfile)) {
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,
});
}
}
for (const savedProfile of savedAgent.profiles) {
const stillExists = draftAgent.profiles.some((profile) => profile.id === savedProfile.id);
if (!stillExists) {
await deleteAgentProfileAction(savedProfile.id);
}
}
await refreshAgents();
}
setSaveStatus('success');
} catch (error) {
setSaveStatus('error');
onToastError(error);
}
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between gap-6">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-bold">
{AGENT_LABELS[draftAgent.name] ?? draftAgent.name}
</h2>
<span className="text-xs text-muted-foreground border border-muted-foreground/30 rounded-full px-2 py-1">
{discoveryAgent?.matched_path ?? 'Installation not detected'}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
Configure profiles and defaults for this agent.
</p>
</div>
</div>
<Separator />
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle>{AGENT_LABELS[draftAgent.name] ?? draftAgent.name} Profiles</CardTitle>
{isAgentDirty && <UnsavedChangesBadge />}
</div>
<Button size="sm" variant="outline" onClick={handleAddProfile}>
<IconPlus className="h-4 w-4 mr-2" />
Add profile
</Button>
</CardHeader>
<CardContent className="space-y-4">
{draftAgent.supports_mcp && (
<div className="space-y-2">
<Label htmlFor="mcp-config-path">MCP Config Path</Label>
<Input
id="mcp-config-path"
value={draftAgent.mcp_config_path ?? ''}
onChange={(event) => handleAgentFieldChange({ mcp_config_path: event.target.value })}
placeholder="~/.copilot/mcp-config.json"
/>
</div>
)}
{draftAgent.profiles.map((profile) => (
<Card key={profile.id} className="border-muted">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2">
<Label>Profile name</Label>
<Input
value={profile.name}
onChange={(event) =>
handleProfileChange(profile.id, { name: event.target.value })
}
placeholder="Default profile"
/>
</div>
<Button size="sm" variant="ghost" onClick={() => handleRemoveProfile(profile.id)}>
Remove
</Button>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={profile.model}
onChange={(event) =>
handleProfileChange(profile.id, { model: event.target.value })
}
placeholder="model-id"
/>
</div>
<div className="space-y-2">
<Label>Append Prompt</Label>
<Textarea
value={profile.plan}
onChange={(event) =>
handleProfileChange(profile.id, { plan: event.target.value })
}
placeholder="Extra text appended to the agent prompt"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Auto-approve</Label>
<p className="text-xs text-muted-foreground">
Automatically approve tool calls.
</p>
</div>
<Switch
checked={profile.auto_approve}
onCheckedChange={(checked) =>
handleProfileChange(profile.id, { auto_approve: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Skip permissions</Label>
<p className="text-xs text-muted-foreground">
Skip permission checks when running tools.
</p>
</div>
<Switch
checked={profile.dangerously_skip_permissions}
onCheckedChange={(checked) =>
handleProfileChange(profile.id, { dangerously_skip_permissions: checked })
}
/>
</div>
</div>
</CardContent>
</Card>
))}
</CardContent>
<div className="flex justify-end px-6 pb-6">
<UnsavedSaveButton
isDirty={isAgentDirty}
isLoading={saveStatus === 'loading'}
status={saveStatus}
onClick={handleSave}
/>
</div>
</Card>
</div>
);
}
export default function AgentSetupPage() {
const { toast } = useToast();
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 discoveryAgent = useMemo(() => {
const agents = discoveryData?.agents ?? [];
return agents.find((agent) => agent.name === decodedKey);
}, [decodedKey, discoveryData?.agents]);
const savedAgent = useMemo(() => {
const agents = agentsData?.agents ?? [];
return agents.find((agent) => agent.id === decodedKey || agent.name === decodedKey) ?? null;
}, [decodedKey, agentsData?.agents]);
const initialAgent = useMemo(() => {
if (!decodedKey) return null;
if (savedAgent) {
return ensureProfiles(cloneAgent(savedAgent));
}
if (discoveryAgent) {
const draft: DraftAgent = {
id: `draft-${generateUUID()}`,
name: discoveryAgent.name,
workspace_id: null,
supports_mcp: discoveryAgent.supports_mcp,
mcp_config_path: discoveryAgent.mcp_config_path ?? '',
profiles: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
isNew: true,
};
return ensureProfiles(draft);
}
return null;
}, [decodedKey, discoveryAgent, savedAgent]);
if (!initialAgent && (discoveryData?.agents?.length ?? 0) > 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Agent not found.</p>
<Button className="mt-4" asChild>
<Link href="/settings/agents">Back to Agents</Link>
</Button>
</CardContent>
</Card>
);
}
if (!initialAgent) {
return null;
}
const handleToastError = (error: unknown) => {
toast({
title: 'Failed to save agent',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
};
return (
<AgentSetupForm
key={initialAgent.id}
initialAgent={initialAgent}
savedAgent={savedAgent}
discoveryAgent={discoveryAgent}
refreshAgents={runListAgents}
onToastError={handleToastError}
/>
);
}
@@ -0,0 +1,252 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { IconTrash } from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { Button } from '@kandev/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Separator } from '@kandev/ui/separator';
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 type { Agent, AgentProfile } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
const AGENT_LABELS: Record<string, string> = {
claude: 'Claude',
gemini: 'Gemini',
codex: 'Codex',
opencode: 'OpenCode',
copilot: 'Copilot',
};
type ProfileEditorProps = {
agent: Agent;
profile: AgentProfile;
onRefresh: () => Promise<unknown>;
};
function ProfileEditor({ agent, profile, onRefresh }: ProfileEditorProps) {
const { toast } = useToast();
const [draft, setDraft] = useState<AgentProfile>({ ...profile });
const [savedProfile, setSavedProfile] = useState<AgentProfile>(profile);
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const isDirty = useMemo(() => {
return (
draft.name !== savedProfile.name ||
draft.model !== savedProfile.model ||
draft.auto_approve !== savedProfile.auto_approve ||
draft.dangerously_skip_permissions !== savedProfile.dangerously_skip_permissions ||
draft.plan !== savedProfile.plan
);
}, [draft, savedProfile]);
const handleSave = async () => {
setSaveStatus('loading');
try {
const updated = await updateAgentProfileAction(draft.id, {
name: draft.name,
model: draft.model,
auto_approve: draft.auto_approve,
dangerously_skip_permissions: draft.dangerously_skip_permissions,
plan: draft.plan,
});
setSavedProfile(updated);
setDraft(updated);
await onRefresh();
setSaveStatus('success');
} catch (error) {
setSaveStatus('error');
toast({
title: 'Failed to save profile',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleDeleteProfile = async () => {
try {
await deleteAgentProfileAction(draft.id);
window.location.assign('/settings/agents');
} catch (error) {
toast({
title: 'Failed to delete profile',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">{savedProfile.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
{AGENT_LABELS[agent.name] ?? agent.name} profile settings
</p>
</div>
<div className="flex items-center gap-3">
{isDirty && <UnsavedChangesBadge />}
<UnsavedSaveButton
isDirty={isDirty}
isLoading={saveStatus === 'loading'}
status={saveStatus}
onClick={handleSave}
/>
</div>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>Profile settings</span>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Profile name</Label>
<Input
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="Default profile"
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={draft.model}
onChange={(event) => setDraft({ ...draft, model: event.target.value })}
placeholder="model-id"
/>
</div>
<div className="space-y-2">
<Label>Append Prompt</Label>
<Textarea
value={draft.plan}
onChange={(event) => setDraft({ ...draft, plan: event.target.value })}
placeholder="Extra text appended to the agent prompt"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Auto-approve</Label>
<p className="text-xs text-muted-foreground">Automatically approve tool calls.</p>
</div>
<Switch
checked={draft.auto_approve}
onCheckedChange={(checked) =>
setDraft({ ...draft, auto_approve: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Skip permissions</Label>
<p className="text-xs text-muted-foreground">
Skip permission checks when running tools.
</p>
</div>
<Switch
checked={draft.dangerously_skip_permissions}
onCheckedChange={(checked) =>
setDraft({ ...draft, dangerously_skip_permissions: checked })
}
/>
</div>
</div>
</CardContent>
</Card>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Delete profile</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Remove this profile</p>
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
</div>
<Button variant="destructive" onClick={handleDeleteProfile}>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
</CardContent>
</Card>
</div>
);
}
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 agent = useMemo(() => {
const agents = data?.agents ?? [];
return agents.find((item) => item.name === agentKey) ?? null;
}, [agentKey, data?.agents]);
const profile = useMemo(() => {
return agent?.profiles.find((item) => item.id === profileId) ?? null;
}, [agent?.profiles, profileId]);
if (!agent || !profile) {
if (isLoading) {
return null;
}
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Profile not found.</p>
<Button className="mt-4" asChild>
<Link href="/settings/agents">Back to Agents</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<ProfileEditor
key={profile.id}
agent={agent}
profile={profile}
onRefresh={runListAgents}
/>
);
}
+122 -203
View File
@@ -1,231 +1,150 @@
'use client';
import { useState } from 'react';
import { IconPlus } from '@tabler/icons-react';
import { useEffect, useMemo, useState } 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 { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Card, CardContent } from '@kandev/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Separator } from '@kandev/ui/separator';
import { AgentCard } from '@/components/settings/agent-card';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { AgentProfile, AgentType } from '@/lib/settings/types';
import { generateUUID } from '@/lib/utils';
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';
const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'codex', label: 'Codex' },
{ value: 'auggie', label: 'Auggie' },
];
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
'claude-code': [
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
],
'codex': [
{ value: 'codex-v2', label: 'Codex v2' },
{ value: 'codex-v1', label: 'Codex v1' },
],
'auggie': [
{ value: 'auggie-v1', label: 'Auggie v1' },
{ value: 'auggie-lite', label: 'Auggie Lite' },
],
const AGENT_LABELS: Record<string, string> = {
claude: 'Claude',
gemini: 'Gemini',
codex: 'Codex',
opencode: 'OpenCode',
copilot: 'Copilot',
};
export default function AgentsSettingsPage() {
const [agents, setAgents] = useState<AgentProfile[]>(SETTINGS_DATA.agents);
const [isAdding, setIsAdding] = useState(false);
const [newAgent, setNewAgent] = useState<{
agent: AgentType;
name: string;
model: string;
autoApprove: boolean;
temperature: number;
}>({
agent: 'claude-code',
name: '',
model: 'claude-sonnet-4.5',
autoApprove: false,
temperature: 0.7,
});
const { toast } = useToast();
const [discoveryAgents, setDiscoveryAgents] = useState<AgentDiscovery[]>([]);
const [savedAgents, setSavedAgents] = useState<Agent[]>([]);
const { run: runListDiscovery } = useRequest(listAgentDiscoveryAction);
const { run: runListAgents } = useRequest(listAgentsAction);
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const profile: AgentProfile = {
id: generateUUID(),
...newAgent,
};
setAgents([...agents, profile]);
setIsAdding(false);
setNewAgent({
agent: 'claude-code',
name: '',
model: 'claude-sonnet-4.5',
autoApprove: false,
temperature: 0.7,
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 handleAgentTypeChange = (type: AgentType) => {
const defaultModel = MODEL_OPTIONS[type][0].value;
setNewAgent({ ...newAgent, agent: type, model: defaultModel });
};
const installedAgents = useMemo(
() => discoveryAgents.filter((agent) => agent.available),
[discoveryAgents]
);
const savedAgentsByName = useMemo(
() => new Map(savedAgents.map((agent) => [agent.name, agent])),
[savedAgents]
);
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">Agents</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure agent profiles and their behavior
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Agent Profile
</Button>
<div>
<h2 className="text-2xl font-bold">Agents</h2>
<p className="text-sm text-muted-foreground mt-1">
Discover installed agents and continue setup from their configuration page.
</p>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-3">
{isAdding && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAdd} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={newAgent.name}
onChange={(e) => setNewAgent({ ...newAgent, name: e.target.value })}
placeholder="My Profile"
required
/>
</div>
<div>
<h3 className="text-lg font-semibold">Local Agents Found</h3>
<p className="text-sm text-muted-foreground">
Agents detected on this machine are ready to configure.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="agent-type">Agent Type</Label>
<Select
value={newAgent.agent}
onValueChange={(value) => handleAgentTypeChange(value as AgentType)}
>
<SelectTrigger id="agent-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<Select
value={newAgent.model}
onValueChange={(value) => setNewAgent({ ...newAgent, model: value })}
>
<SelectTrigger id="model">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_OPTIONS[newAgent.agent].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{installedAgents.length === 0 && (
<Card>
<CardContent className="py-8 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<IconAlertTriangle className="h-4 w-4" />
No installed agents were detected yet.
</div>
</CardContent>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{installedAgents.map((agent) => {
const savedAgent = savedAgentsByName.get(agent.name);
const configured = Boolean(savedAgent && savedAgent.profiles.length > 0);
const hasAgentRecord = Boolean(savedAgent);
return (
<Card key={agent.name}>
<CardContent className="py-4 flex flex-col gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{AGENT_LABELS[agent.name] ?? agent.name}</h4>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
{configured && <Badge variant="outline">Configured</Badge>}
</div>
{agent.matched_path && (
<p className="text-xs text-muted-foreground">Detected at {agent.matched_path}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="temperature">Temperature: {newAgent.temperature}</Label>
<input
id="temperature"
type="range"
min="0"
max="1"
step="0.1"
value={newAgent.temperature}
onChange={(e) =>
setNewAgent({ ...newAgent, temperature: parseFloat(e.target.value) })
}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Precise</span>
<span>Creative</span>
</div>
</div>
<div className="flex items-center justify-between p-4 border rounded-md">
<div>
<Label htmlFor="auto-approve" className="text-base font-medium">
Auto-approve all actions
</Label>
<p className="text-sm text-muted-foreground">
Automatically approve agent actions without prompting
</p>
</div>
<button
id="auto-approve"
type="button"
role="switch"
aria-checked={newAgent.autoApprove}
onClick={() =>
setNewAgent({ ...newAgent, autoApprove: !newAgent.autoApprove })
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
newAgent.autoApprove ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
newAgent.autoApprove ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
Cancel
</Button>
<Button type="submit">Add Profile</Button>
</div>
</form>
</CardContent>
</Card>
)}
{agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
/>
))}
{agents.length === 0 && !isAdding && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No agent profiles configured. Add your first profile to get started.
</p>
</CardContent>
</Card>
)}
<Button size="sm" className="cursor-pointer" asChild>
<Link href={`/settings/agents/${encodeURIComponent(agent.name)}`}>
<IconSettings className="h-4 w-4 mr-2" />
{hasAgentRecord ? 'Create new profile' : 'Setup Profile'}
</Link>
</Button>
</CardContent>
</Card>
);
})}
</div>
</div>
{savedAgents.some((agent) => agent.profiles.length > 0) && (
<div className="space-y-4">
<Separator />
<div>
<h3 className="text-lg font-semibold">Agent Profiles</h3>
<p className="text-sm text-muted-foreground">Manage existing profiles by agent.</p>
</div>
<div className="space-y-2">
{savedAgents.flatMap((agent) =>
agent.profiles.map((profile) => {
const profilePath = `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${profile.id}`;
return (
<Link key={profile.id} href={profilePath} className="block">
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{AGENT_LABELS[agent.name] ?? agent.name}
</span>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
<span className="text-sm text-muted-foreground">{profile.name}</span>
</div>
</CardContent>
</Card>
</Link>
);
})
)}
</div>
</div>
)}
</div>
);
}
+3 -1
View File
@@ -17,7 +17,7 @@ import { getBackendConfig } from '@/lib/config';
import { listRepositories, listRepositoryBranches } from '@/lib/http/client';
import { useRequest } from '@/lib/http/use-request';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStoreApi } from '@/components/state-provider';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
const AGENTS = [
{ id: 'codex', label: 'Codex' },
@@ -48,6 +48,8 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
// Use task from props but allow updates
const task = initialTask;
// Track task state from store (kanban.tasks) to determine if agent is running
useAppStore((state) => state.kanban.tasks.find((t) => t.id === task?.id));
useEffect(() => {
setIsMounted(true);
}, []);
+1 -1
View File
@@ -20,7 +20,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const agentLabel = AGENT_LABELS[agent.agent] || agent.agent;
return (
<Link href={`/settings/agent/${agent.id}`}>
<Link href={`/settings/agents/${agent.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between">
@@ -34,11 +34,14 @@ import { listEnvironmentsAction } from '@/app/actions/environments';
import { listExecutorsAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Environment, Executor } from '@/lib/types/http';
import { getBackendConfig } from '@/lib/config';
import type { Agent } from '@/lib/types/http';
import { useAppStore } from '@/components/state-provider';
export function SettingsAppSidebar() {
const pathname = usePathname();
const workspaces = useAppStore((state) => state.workspaces.items);
const agentProfilesVersion = useAppStore((state) => state.agentProfiles.version);
const fallbackEnvironments = React.useMemo<Environment[]>(
() =>
SETTINGS_DATA.environments.map((env) => ({
@@ -74,8 +77,8 @@ export function SettingsAppSidebar() {
[]
);
const [environments, setEnvironments] = React.useState<Environment[]>(fallbackEnvironments);
const [agents] = React.useState(SETTINGS_DATA.agents);
const [executors, setExecutors] = React.useState<Executor[]>(fallbackExecutors);
const [agents, setAgents] = React.useState<Agent[]>([]);
React.useEffect(() => {
const client = getWebSocketClient();
@@ -194,6 +197,22 @@ export function SettingsAppSidebar() {
unsubscribeExecutorDeleted();
};
}, []);
React.useEffect(() => {
const { apiBaseUrl } = getBackendConfig();
const controller = new AbortController();
fetch(`${apiBaseUrl}/api/v1/agents`, {
cache: 'no-store',
signal: controller.signal,
})
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (data?.agents) {
setAgents(data.agents);
}
})
.catch(() => undefined);
return () => controller.abort();
}, [agentProfilesVersion, pathname]);
return (
<Sidebar variant="inset">
@@ -392,18 +411,24 @@ export function SettingsAppSidebar() {
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{agents.map((agent) => (
<SidebarMenuSubItem key={agent.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/agent/${agent.id}`}
>
<Link href={`/settings/agent/${agent.id}`}>
<span>{agent.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
{agents.flatMap((agent) =>
agent.profiles.map((profile) => {
const encodedAgent = encodeURIComponent(agent.name);
const profilePath = `/settings/agents/${encodedAgent}/profiles/${profile.id}`;
return (
<SidebarMenuSubItem key={profile.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === profilePath}
>
<Link href={profilePath}>
<span>{profile.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})
)}
</SidebarMenuSub>
</CollapsibleContent>
</>
@@ -1,6 +1,7 @@
'use client';
import { IconArrowLeft } from '@tabler/icons-react';
import { usePathname } from 'next/navigation';
import {
Breadcrumb,
BreadcrumbItem,
@@ -12,6 +13,11 @@ import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
import { TooltipProvider } from '@kandev/ui/tooltip';
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAgentDetail = pathname.startsWith('/settings/agents/') && pathname !== '/settings/agents';
const breadcrumbLabel = isAgentDetail ? 'Agents' : 'Home';
const breadcrumbHref = isAgentDetail ? '/settings/agents' : '/';
return (
<TooltipProvider>
<SidebarProvider>
@@ -22,9 +28,9 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/" className="flex items-center gap-2">
<BreadcrumbLink href={breadcrumbHref} className="flex items-center gap-2">
<IconArrowLeft className="h-4 w-4" />
Home
{breadcrumbLabel}
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
+171
View File
@@ -25,6 +25,21 @@ import type { Repository, Task } from '@/lib/types/http';
import { getBackendConfig } from '@/lib/config';
import { createTask, listRepositories, listRepositoryBranches, updateTask } from '@/lib/http/client';
type AgentProfileOption = {
id: string;
label: string;
};
type EnvironmentOption = {
id: string;
name: string;
};
type ExecutorOption = {
id: string;
name: string;
};
interface TaskCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -67,6 +82,15 @@ export function TaskCreateDialog({
const [repositoriesLoading, setRepositoriesLoading] = useState(false);
const [branchesByRepo, setBranchesByRepo] = useState<Record<string, string[]>>({});
const [branchesLoading, setBranchesLoading] = useState(false);
const [agentProfileId, setAgentProfileId] = useState('');
const [agentProfiles, setAgentProfiles] = useState<AgentProfileOption[]>([]);
const [agentProfilesLoading, setAgentProfilesLoading] = useState(false);
const [environmentId, setEnvironmentId] = useState('');
const [environments, setEnvironments] = useState<EnvironmentOption[]>([]);
const [environmentsLoading, setEnvironmentsLoading] = useState(false);
const [executorId, setExecutorId] = useState('');
const [executors, setExecutors] = useState<ExecutorOption[]>([]);
const [executorsLoading, setExecutorsLoading] = useState(false);
const descriptionRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
@@ -77,6 +101,9 @@ export function TaskCreateDialog({
setBranch(initialValues?.branch ?? '');
setTaskState(initialValues?.state ?? editingTask?.state ?? 'CREATED');
setStartAgent(false);
setAgentProfileId('');
setEnvironmentId('');
setExecutorId('');
}, [
editingTask?.state,
initialValues?.branch,
@@ -121,6 +148,70 @@ export function TaskCreateDialog({
});
}, [branchesByRepo, repositoryId]);
useEffect(() => {
if (!open) return;
setAgentProfilesLoading(true);
fetch(`${getBackendConfig().apiBaseUrl}/api/v1/agents`, { cache: 'no-store' })
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (!data?.agents) {
setAgentProfiles([]);
return;
}
const options = data.agents.flatMap(
(agent: { name: string; profiles: Array<{ id: string; name: string }> }) =>
agent.profiles.map((profile) => ({
id: profile.id,
label: `${agent.name} • ${profile.name}`,
}))
);
setAgentProfiles(options);
})
.catch(() => {
setAgentProfiles([]);
})
.finally(() => {
setAgentProfilesLoading(false);
});
}, [open]);
useEffect(() => {
if (!open) return;
const apiBaseUrl = getBackendConfig().apiBaseUrl;
setEnvironmentsLoading(true);
setExecutorsLoading(true);
Promise.all([
fetch(`${apiBaseUrl}/api/v1/environments`, { cache: 'no-store' })
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (!data?.environments) return [];
return data.environments.map((env: { id: string; name: string }) => ({
id: env.id,
name: env.name,
}));
})
.catch(() => []),
fetch(`${apiBaseUrl}/api/v1/executors`, { cache: 'no-store' })
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (!data?.executors) return [];
return data.executors.map((executor: { id: string; name: string }) => ({
id: executor.id,
name: executor.name,
}));
})
.catch(() => []),
])
.then(([nextEnvironments, nextExecutors]) => {
setEnvironments(nextEnvironments);
setExecutors(nextExecutors);
})
.finally(() => {
setEnvironmentsLoading(false);
setExecutorsLoading(false);
});
}, [open]);
useEffect(() => {
const textarea = descriptionRef.current;
if (!textarea) return;
@@ -147,6 +238,7 @@ export function TaskCreateDialog({
setBranch('');
setTaskState('CREATED');
setStartAgent(false);
setAgentProfileId('');
onOpenChange(false);
}
return;
@@ -174,6 +266,8 @@ export function TaskCreateDialog({
repository_url: repository?.local_path ?? '',
branch: branch || '',
agent_type: startAgent ? 'default' : '',
environment_id: environmentId || undefined,
executor_id: executorId || undefined,
state: targetState,
});
onSuccess?.(task, 'create');
@@ -184,6 +278,9 @@ export function TaskCreateDialog({
setBranch('');
setTaskState('CREATED');
setStartAgent(false);
setAgentProfileId('');
setEnvironmentId('');
setExecutorId('');
onOpenChange(false);
}
};
@@ -195,6 +292,9 @@ export function TaskCreateDialog({
setBranch('');
setTaskState('CREATED');
setStartAgent(false);
setAgentProfileId('');
setEnvironmentId('');
setExecutorId('');
onOpenChange(false);
};
@@ -296,6 +396,77 @@ export function TaskCreateDialog({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Agent Profile</Label>
<Select value={agentProfileId} onValueChange={setAgentProfileId}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
agentProfilesLoading
? 'Loading agent profiles...'
: agentProfiles.length === 0
? 'No profiles available'
: 'Select agent profile'
}
/>
</SelectTrigger>
<SelectContent>
{agentProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Environment</Label>
<Select value={environmentId} onValueChange={setEnvironmentId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
environmentsLoading
? 'Loading environments...'
: environments.length === 0
? 'No environments available'
: 'Select environment'
}
/>
</SelectTrigger>
<SelectContent>
{environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Executor</Label>
<Select value={executorId} onValueChange={setExecutorId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
executorsLoading
? 'Loading executors...'
: executors.length === 0
? 'No executors available'
: 'Select executor'
}
/>
</SelectTrigger>
<SelectContent>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="start-agent"
@@ -35,7 +35,6 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
}
return Object.values(gitStatus.files);
}, [gitStatus.files]);
const diffTargets = useMemo(
() => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file) => file.path)),
[selectedDiffPath, changedFiles]
+2 -5
View File
@@ -55,7 +55,7 @@ type ToolCallMetadata = {
result?: string;
};
function getToolIconElement(toolName?: string, className?: string) {
function getToolIcon(toolName: string | undefined, className: string) {
const name = toolName?.toLowerCase() ?? '';
// Match ACP ToolKind values: read, edit, delete, move, search, execute
if (name === 'edit' || name.includes('edit') || name.includes('replace') || name.includes('write') || name.includes('save')) {
@@ -99,10 +99,7 @@ function ToolCallCard({ comment }: { comment: Comment }) {
const args = metadata?.args;
const result = metadata?.result;
const toolIcon = getToolIconElement(
toolName,
'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0'
);
const toolIcon = getToolIcon(toolName, 'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0');
const hasDetails = args && Object.keys(args).length > 0;
// Extract file path from various possible sources
+2
View File
@@ -63,6 +63,8 @@ export async function createTask(
repository_url?: string;
branch?: string;
agent_type?: string;
environment_id?: string;
executor_id?: string;
state?: Task['state'];
}
) {
+33 -1
View File
@@ -34,6 +34,10 @@ export type AgentState = {
agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>;
};
export type AgentProfilesState = {
version: number;
};
export type TerminalState = {
terminals: Array<{ id: string; output: string[] }>;
};
@@ -83,6 +87,7 @@ export type AppState = {
boards: BoardState;
tasks: TaskState;
agents: AgentState;
agentProfiles: AgentProfilesState;
terminal: TerminalState;
diffs: DiffState;
gitStatus: GitStatusState;
@@ -101,6 +106,7 @@ export type AppState = {
setCommentsLoading: (loading: boolean) => void;
setGitStatus: (taskId: string, gitStatus: Omit<GitStatusState, 'taskId'>) => void;
clearGitStatus: () => void;
bumpAgentProfilesVersion: () => void;
};
export type AppStore = ReturnType<typeof createAppStore>;
@@ -111,6 +117,7 @@ const defaultState: AppState = {
boards: { items: [], activeId: null },
tasks: { activeTaskId: null },
agents: { agents: [] },
agentProfiles: { version: 0 },
terminal: { terminals: [] },
diffs: { files: [] },
gitStatus: {
@@ -142,9 +149,28 @@ const defaultState: AppState = {
setCommentsLoading: () => undefined,
setGitStatus: () => undefined,
clearGitStatus: () => undefined,
bumpAgentProfilesVersion: () => undefined,
};
function mergeInitialState(initialState?: Partial<AppState>): Omit<AppState, 'hydrate' | 'setActiveWorkspace' | 'setWorkspaces' | 'setActiveBoard' | 'setBoards' | 'setTerminalOutput' | 'setConnectionStatus' | 'setComments' | 'setCommentsTaskId' | 'addComment' | 'setCommentsLoading' | 'setGitStatus' | 'clearGitStatus'> {
function mergeInitialState(
initialState?: Partial<AppState>
): Omit<
AppState,
| 'hydrate'
| 'setActiveWorkspace'
| 'setWorkspaces'
| 'setActiveBoard'
| 'setBoards'
| 'setTerminalOutput'
| 'setConnectionStatus'
| 'setComments'
| 'setCommentsTaskId'
| 'addComment'
| 'setCommentsLoading'
| 'setGitStatus'
| 'clearGitStatus'
| 'bumpAgentProfilesVersion'
> {
if (!initialState) return defaultState;
return {
workspaces: { ...defaultState.workspaces, ...initialState.workspaces },
@@ -152,6 +178,7 @@ function mergeInitialState(initialState?: Partial<AppState>): Omit<AppState, 'hy
kanban: { ...defaultState.kanban, ...initialState.kanban },
tasks: { ...defaultState.tasks, ...initialState.tasks },
agents: { ...defaultState.agents, ...initialState.agents },
agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles },
terminal: { ...defaultState.terminal, ...initialState.terminal },
diffs: { ...defaultState.diffs, ...initialState.diffs },
gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus },
@@ -173,6 +200,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
if (state.kanban) Object.assign(draft.kanban, state.kanban);
if (state.tasks) Object.assign(draft.tasks, state.tasks);
if (state.agents) Object.assign(draft.agents, state.agents);
if (state.agentProfiles) Object.assign(draft.agentProfiles, state.agentProfiles);
if (state.terminal) Object.assign(draft.terminal, state.terminal);
if (state.diffs) Object.assign(draft.diffs, state.diffs);
if (state.connection) Object.assign(draft.connection, state.connection);
@@ -272,6 +300,10 @@ export function createAppStore(initialState?: Partial<AppState>) {
timestamp: null,
};
}),
bumpAgentProfilesVersion: () =>
set((draft) => {
draft.agentProfiles.version += 1;
}),
}))
);
}
+16 -1
View File
@@ -24,7 +24,10 @@ export type BackendMessageType =
| 'executor.deleted'
| 'environment.created'
| 'environment.updated'
| 'environment.deleted';
| 'environment.deleted'
| 'agent.profile.deleted'
| 'agent.profile.created'
| 'agent.profile.updated';
export type BackendMessage<T extends BackendMessageType, P> = {
id?: string;
@@ -175,6 +178,15 @@ export type EnvironmentPayload = {
updated_at?: string;
};
export type AgentProfileDeletedPayload = {
profile_id: string;
};
export type AgentProfileChangedPayload = {
profile_id: string;
agent_id: string;
};
export type BackendMessageMap = {
'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>;
'task.created': BackendMessage<'task.created', TaskEventPayload>;
@@ -202,4 +214,7 @@ export type BackendMessageMap = {
'environment.created': BackendMessage<'environment.created', EnvironmentPayload>;
'environment.updated': BackendMessage<'environment.updated', EnvironmentPayload>;
'environment.deleted': BackendMessage<'environment.deleted', EnvironmentPayload>;
'agent.profile.deleted': BackendMessage<'agent.profile.deleted', AgentProfileDeletedPayload>;
'agent.profile.created': BackendMessage<'agent.profile.created', AgentProfileChangedPayload>;
'agent.profile.updated': BackendMessage<'agent.profile.updated', AgentProfileChangedPayload>;
};
+42
View File
@@ -193,3 +193,45 @@ export type Comment = {
requests_input?: boolean;
created_at: string;
};
export type AgentProfile = {
id: string;
agent_id: string;
name: string;
model: string;
auto_approve: boolean;
dangerously_skip_permissions: boolean;
plan: string;
created_at: string;
updated_at: string;
};
export type Agent = {
id: string;
name: string;
workspace_id?: string | null;
supports_mcp: boolean;
mcp_config_path?: string | null;
profiles: AgentProfile[];
created_at: string;
updated_at: string;
};
export type AgentDiscovery = {
name: string;
supports_mcp: boolean;
mcp_config_path?: string | null;
installation_paths: string[];
available: boolean;
matched_path?: string | null;
};
export type ListAgentsResponse = {
agents: Agent[];
total: number;
};
export type ListAgentDiscoveryResponse = {
agents: AgentDiscovery[];
total: number;
};
+9
View File
@@ -378,5 +378,14 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
timestamp: payload.timestamp,
});
},
'agent.profile.deleted': () => {
store.getState().bumpAgentProfilesVersion();
},
'agent.profile.created': () => {
store.getState().bumpAgentProfilesVersion();
},
'agent.profile.updated': () => {
store.getState().bumpAgentProfilesVersion();
},
};
}