diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index f5621f32d3..15bb5a6183 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -229,6 +229,10 @@ func main() { ) log.Info("Task Service initialized") + if err := runInitialAgentSetup(ctx, userSvc, agentSettingsController, log); err != nil { + log.Warn("Failed to run initial agent setup", zap.Error(err)) + } + // ACP messages are now stored as comments in the task_comments table // The comment system provides unified storage for all task-related communication log.Info("ACP messages will be stored as comments") @@ -829,6 +833,32 @@ func main() { log.Info("Kandev stopped") } +func runInitialAgentSetup( + ctx context.Context, + userSvc *userservice.Service, + agentSettingsController *agentsettingscontroller.Controller, + log *logger.Logger, +) error { + settings, err := userSvc.GetUserSettings(ctx) + if err != nil { + return err + } + if settings.InitialSetupComplete { + return nil + } + if err := agentSettingsController.EnsureInitialAgentProfiles(ctx); err != nil { + return err + } + complete := true + if _, err := userSvc.UpdateUserSettings(ctx, &userservice.UpdateUserSettingsRequest{ + InitialSetupComplete: &complete, + }); err != nil { + return err + } + log.Info("Initial agent setup complete") + return nil +} + // taskRepositoryAdapter adapts the task repository for the orchestrator's scheduler type taskRepositoryAdapter struct { repo repository.Repository diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index e044a14f73..a37ce712f1 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -21,6 +21,8 @@ var ( ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session") ) +const defaultAgentProfileName = "default" + type Controller struct { repo store.Repository discovery *discovery.Registry @@ -76,6 +78,49 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil } +func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { + results, err := c.discovery.Detect(ctx) + if err != nil { + return err + } + for _, result := range results { + if !result.Available { + continue + } + agent, err := c.repo.GetAgentByName(ctx, result.Name) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if errors.Is(err, sql.ErrNoRows) || agent == nil { + agent = &models.Agent{ + Name: result.Name, + SupportsMCP: result.SupportsMCP, + MCPConfigPath: result.MCPConfigPath, + } + if err := c.repo.CreateAgent(ctx, agent); err != nil { + return err + } + } + profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return err + } + if len(profiles) > 0 { + continue + } + defaultProfile := &models.AgentProfile{ + AgentID: agent.ID, + Name: defaultAgentProfileName, + Model: "", + Plan: "", + } + if err := c.repo.CreateAgentProfile(ctx, defaultProfile); err != nil { + return err + } + } + return nil +} + func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) { agent, err := c.repo.GetAgent(ctx, id) if err != nil { diff --git a/apps/backend/internal/task/controller/repository.go b/apps/backend/internal/task/controller/repository.go index f1043df266..ad492162aa 100644 --- a/apps/backend/internal/task/controller/repository.go +++ b/apps/backend/internal/task/controller/repository.go @@ -163,6 +163,25 @@ func (c *RepositoryController) ListRepositoryBranches(ctx context.Context, req d }, nil } +func (c *RepositoryController) ListLocalRepositoryBranches(ctx context.Context, req dto.ListLocalRepositoryBranchesRequest) (dto.RepositoryBranchesResponse, error) { + branches, err := c.service.ListLocalRepositoryBranches(ctx, req.Path) + if err != nil { + return dto.RepositoryBranchesResponse{}, err + } + dtoBranches := make([]dto.BranchDTO, len(branches)) + for i, branch := range branches { + dtoBranches[i] = dto.BranchDTO{ + Name: branch.Name, + Type: branch.Type, + Remote: branch.Remote, + } + } + return dto.RepositoryBranchesResponse{ + Branches: dtoBranches, + Total: len(dtoBranches), + }, nil +} + func (c *RepositoryController) DiscoverRepositories(ctx context.Context, req dto.DiscoverRepositoriesRequest) (dto.RepositoryDiscoveryResponse, error) { result, err := c.service.DiscoverLocalRepositories(ctx, req.Root) if err != nil { diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index afac24616d..9950bb7cf7 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -14,8 +14,8 @@ type WorktreeLookup interface { } type TaskController struct { - service *service.Service - worktreeLookup WorktreeLookup + service *service.Service + worktreeLookup WorktreeLookup } func NewTaskController(svc *service.Service) *TaskController { @@ -84,23 +84,26 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque var repos []service.TaskRepositoryInput for _, r := range req.Repositories { repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, }) } task, err := c.service.CreateTask(ctx, &service.CreateTaskRequest{ - WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - ColumnID: req.ColumnID, - Title: req.Title, - Description: req.Description, - Priority: req.Priority, - State: req.State, - Repositories: repos, - AssignedTo: req.AssignedTo, - Position: req.Position, - Metadata: req.Metadata, + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + ColumnID: req.ColumnID, + Title: req.Title, + Description: req.Description, + Priority: req.Priority, + State: req.State, + Repositories: repos, + AssignedTo: req.AssignedTo, + Position: req.Position, + Metadata: req.Metadata, }) if err != nil { return dto.TaskDTO{}, err @@ -113,8 +116,11 @@ func (c *TaskController) UpdateTask(ctx context.Context, req dto.UpdateTaskReque var repos []service.TaskRepositoryInput for _, r := range req.Repositories { repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, }) } diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index e30ad97798..19260ec4e4 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -206,6 +206,11 @@ type ListRepositoryBranchesRequest struct { ID string } +type ListLocalRepositoryBranchesRequest struct { + WorkspaceID string + Path string +} + type DiscoverRepositoriesRequest struct { WorkspaceID string Root string @@ -238,22 +243,25 @@ type GetTaskRequest struct { } type TaskRepositoryInput struct { - RepositoryID string - BaseBranch string + RepositoryID string + BaseBranch string + LocalPath string + Name string + DefaultBranch string } type CreateTaskRequest struct { - WorkspaceID string - BoardID string - ColumnID string - Title string - Description string - Priority int - State *v1.TaskState - Repositories []TaskRepositoryInput - AssignedTo string - Position int - Metadata map[string]interface{} + WorkspaceID string + BoardID string + ColumnID string + Title string + Description string + Priority int + State *v1.TaskState + Repositories []TaskRepositoryInput + AssignedTo string + Position int + Metadata map[string]interface{} } type UpdateTaskRequest struct { @@ -274,10 +282,10 @@ type DeleteTaskRequest struct { type ListMessagesRequest struct { TaskSessionID string - Limit int - Before string - After string - Sort string + Limit int + Before string + After string + Sort string } type CreateMessageRequest struct { diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 1b4fcb402e..dda5607098 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -38,6 +38,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api.GET("/workspaces/:id/repositories", h.httpListRepositories) api.POST("/workspaces/:id/repositories", h.httpCreateRepository) api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories) + api.GET("/workspaces/:id/repositories/branches", h.httpListLocalRepositoryBranches) api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath) api.GET("/repositories/:id", h.httpGetRepository) api.GET("/repositories/:id/branches", h.httpListRepositoryBranches) @@ -111,6 +112,28 @@ func (h *RepositoryHandlers) httpValidateRepositoryPath(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *RepositoryHandlers) httpListLocalRepositoryBranches(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) + return + } + resp, err := h.controller.ListLocalRepositoryBranches(c.Request.Context(), dto.ListLocalRepositoryBranchesRequest{ + WorkspaceID: c.Param("id"), + Path: path, + }) + if err != nil { + if errors.Is(err, service.ErrPathNotAllowed) { + c.JSON(http.StatusBadRequest, gin.H{"error": "path is not within allowed roots"}) + return + } + h.logger.Error("failed to list local repository branches", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list local repository branches"}) + return + } + c.JSON(http.StatusOK, resp) +} + type httpCreateRepositoryRequest struct { Name string `json:"name"` SourceType string `json:"source_type"` diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 183c7e577a..4e8fad4b85 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -84,22 +84,25 @@ func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) { } type httpTaskRepositoryInput struct { - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + LocalPath string `json:"local_path"` + Name string `json:"name"` + DefaultBranch string `json:"default_branch"` } type httpCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Priority int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo string `json:"assigned_to,omitempty"` - Position int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (h *TaskHandlers) httpCreateTask(c *gin.Context) { @@ -116,9 +119,16 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { // Convert repositories var repos []dto.TaskRepositoryInput for _, r := range body.Repositories { + if r.RepositoryID == "" && r.LocalPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or local_path is required"}) + return + } repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, }) } @@ -165,8 +175,11 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { if body.Repositories != nil { for _, r := range body.Repositories { repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, }) } } @@ -272,17 +285,17 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me } type wsCreateTaskRequest struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - ColumnID string `json:"column_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Priority int `json:"priority,omitempty"` - State *v1.TaskState `json:"state,omitempty"` - Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` - AssignedTo string `json:"assigned_to,omitempty"` - Position int `json:"position,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + ColumnID string `json:"column_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` + State *v1.TaskState `json:"state,omitempty"` + Repositories []httpTaskRepositoryInput `json:"repositories,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Position int `json:"position,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -306,9 +319,15 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M // Convert repositories var repos []dto.TaskRepositoryInput for _, r := range req.Repositories { + if r.RepositoryID == "" && r.LocalPath == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id or local_path is required", nil) + } repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, }) } @@ -378,8 +397,11 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Repositories != nil { for _, r := range req.Repositories { repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, }) } } diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 75a50d4dd5..9c9d763084 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -148,6 +148,27 @@ func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([] return listGitBranches(absPath) } +func (s *Service) ListLocalRepositoryBranches(ctx context.Context, path string) ([]Branch, error) { + if path == "" { + return nil, fmt.Errorf("repository path is required") + } + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("invalid repository path: %w", err) + } + if !isPathAllowed(absPath, s.discoveryRoots()) { + return nil, ErrPathNotAllowed + } + info, err := os.Stat(absPath) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("repository path is not a directory") + } + return listGitBranches(absPath) +} + func (s *Service) discoveryRoots() []string { if len(s.discoveryConfig.Roots) > 0 { return normalizeRoots(s.discoveryConfig.Roots) @@ -344,15 +365,32 @@ func resolveGitDir(repoPath string) (string, error) { return filepath.Clean(filepath.Join(repoPath, gitDir)), nil } +func resolveCommonGitDir(gitDir string) string { + commonFile := filepath.Join(gitDir, "commondir") + content, err := os.ReadFile(commonFile) + if err != nil { + return gitDir + } + commonDir := strings.TrimSpace(string(content)) + if commonDir == "" { + return gitDir + } + if filepath.IsAbs(commonDir) { + return filepath.Clean(commonDir) + } + return filepath.Clean(filepath.Join(gitDir, commonDir)) +} + func listGitBranches(repoPath string) ([]Branch, error) { gitDir, err := resolveGitDir(repoPath) if err != nil { return nil, err } + refsRoot := resolveCommonGitDir(gitDir) branchMap := make(map[string]Branch) // List local branches from refs/heads - localRefsRoot := filepath.Join(gitDir, "refs", "heads") + localRefsRoot := filepath.Join(refsRoot, "refs", "heads") _ = filepath.WalkDir(localRefsRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil @@ -376,7 +414,7 @@ func listGitBranches(repoPath string) ([]Branch, error) { }) // List remote branches from refs/remotes - remoteRefsRoot := filepath.Join(gitDir, "refs", "remotes") + remoteRefsRoot := filepath.Join(refsRoot, "refs", "remotes") _ = filepath.WalkDir(remoteRefsRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil @@ -412,7 +450,7 @@ func listGitBranches(repoPath string) ([]Branch, error) { }) // Read packed-refs file for additional branches - packedRefs := filepath.Join(gitDir, "packed-refs") + packedRefs := filepath.Join(refsRoot, "packed-refs") if content, err := os.ReadFile(packedRefs); err == nil { lines := strings.Split(string(content), "\n") for _, line := range lines { diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 2860b14f6d..0d8131b57b 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "strings" "time" @@ -65,8 +66,11 @@ func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) { // TaskRepositoryInput for creating/updating task repositories type TaskRepositoryInput struct { - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + LocalPath string `json:"local_path,omitempty"` + Name string `json:"name,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` } // CreateTaskRequest contains the data for creating a new task @@ -213,10 +217,10 @@ type UpdateEnvironmentRequest struct { type ListMessagesRequest struct { TaskSessionID string - Limit int - Before string - After string - Sort string + Limit int + Before string + After string + Sort string } // CreateRepositoryScriptRequest contains the data for creating a repository script @@ -262,11 +266,64 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode } // Create task-repository associations + var repoByPath map[string]*models.Repository + for _, repoInput := range req.Repositories { + if repoInput.RepositoryID == "" && repoInput.LocalPath != "" { + repos, err := s.repo.ListRepositories(ctx, req.WorkspaceID) + if err != nil { + s.logger.Error("failed to list repositories", zap.Error(err)) + return nil, err + } + repoByPath = make(map[string]*models.Repository, len(repos)) + for _, repo := range repos { + if repo.LocalPath == "" { + continue + } + repoByPath[repo.LocalPath] = repo + } + break + } + } + for i, repoInput := range req.Repositories { + repositoryID := repoInput.RepositoryID + baseBranch := repoInput.BaseBranch + if repositoryID == "" && repoInput.LocalPath != "" { + repo := repoByPath[repoInput.LocalPath] + if repo == nil { + name := strings.TrimSpace(repoInput.Name) + if name == "" { + name = filepath.Base(repoInput.LocalPath) + } + defaultBranch := repoInput.DefaultBranch + if defaultBranch == "" { + defaultBranch = repoInput.BaseBranch + } + created, err := s.CreateRepository(ctx, &CreateRepositoryRequest{ + WorkspaceID: req.WorkspaceID, + Name: name, + SourceType: "local", + LocalPath: repoInput.LocalPath, + DefaultBranch: defaultBranch, + }) + if err != nil { + return nil, err + } + repo = created + repoByPath[repoInput.LocalPath] = repo + } + repositoryID = repo.ID + if baseBranch == "" { + baseBranch = repo.DefaultBranch + } + } + if repositoryID == "" { + return nil, fmt.Errorf("repository_id is required") + } taskRepo := &models.TaskRepository{ TaskID: task.ID, - RepositoryID: repoInput.RepositoryID, - BaseBranch: repoInput.BaseBranch, + RepositoryID: repositoryID, + BaseBranch: baseBranch, Position: i, Metadata: make(map[string]interface{}), } @@ -1352,13 +1409,13 @@ func (s *Service) publishEnvironmentEvent(ctx context.Context, eventType string, // CreateMessageRequest contains the data for creating a new message type CreateMessageRequest struct { TaskSessionID string `json:"task_session_id"` - TaskID string `json:"task_id,omitempty"` - Content string `json:"content"` - AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" - AuthorID string `json:"author_id,omitempty"` - RequestsInput bool `json:"requests_input,omitempty"` - Type string `json:"type,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + TaskID string `json:"task_id,omitempty"` + Content string `json:"content"` + AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" + AuthorID string `json:"author_id,omitempty"` + RequestsInput bool `json:"requests_input,omitempty"` + Type string `json:"type,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } // CreateMessage creates a new message on an agent session @@ -1384,16 +1441,16 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) } message := &models.Message{ - ID: uuid.New().String(), + ID: uuid.New().String(), TaskSessionID: req.TaskSessionID, - TaskID: taskID, - AuthorType: authorType, - AuthorID: req.AuthorID, - Content: req.Content, - Type: messageType, - Metadata: req.Metadata, - RequestsInput: req.RequestsInput, - CreatedAt: time.Now().UTC(), + TaskID: taskID, + AuthorType: authorType, + AuthorID: req.AuthorID, + Content: req.Content, + Type: messageType, + Metadata: req.Metadata, + RequestsInput: req.RequestsInput, + CreatedAt: time.Now().UTC(), } if err := s.repo.CreateMessage(ctx, message); err != nil { @@ -1501,15 +1558,15 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes } data := map[string]interface{}{ - "message_id": message.ID, + "message_id": message.ID, "task_session_id": message.TaskSessionID, - "task_id": message.TaskID, - "author_type": string(message.AuthorType), - "author_id": message.AuthorID, - "content": message.Content, - "type": messageType, - "requests_input": message.RequestsInput, - "created_at": message.CreatedAt.Format(time.RFC3339), + "task_id": message.TaskID, + "author_type": string(message.AuthorType), + "author_id": message.AuthorID, + "content": message.Content, + "type": messageType, + "requests_input": message.RequestsInput, + "created_at": message.CreatedAt.Format(time.RFC3339), } if message.Metadata != nil { diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index e8a4335da8..97d68eb8d6 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -40,9 +40,10 @@ func (c *Controller) GetUserSettings(ctx context.Context) (dto.UserSettingsRespo func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) { settings, err := c.svc.UpdateUserSettings(ctx, &service.UpdateUserSettingsRequest{ - WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - RepositoryIDs: req.RepositoryIDs, + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + RepositoryIDs: req.RepositoryIDs, + InitialSetupComplete: req.InitialSetupComplete, }) if err != nil { return dto.UserSettingsResponse{}, err diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 7db55d982b..4f98a8cf9f 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -14,15 +14,16 @@ type UserDTO struct { } type UserSettingsDTO struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` - UpdatedAt string `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + InitialSetupComplete bool `json:"initial_setup_complete"` + UpdatedAt string `json:"updated_at"` } type UserResponse struct { - User UserDTO `json:"user"` + User UserDTO `json:"user"` Settings UserSettingsDTO `json:"settings"` } @@ -31,9 +32,10 @@ type UserSettingsResponse struct { } type UpdateUserSettingsRequest struct { - WorkspaceID *string `json:"workspace_id,omitempty"` - BoardID *string `json:"board_id,omitempty"` - RepositoryIDs *[]string `json:"repository_ids,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` + InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` } func FromUser(user *models.User) UserDTO { @@ -47,10 +49,11 @@ func FromUser(user *models.User) UserDTO { func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { return UserSettingsDTO{ - UserID: settings.UserID, - WorkspaceID: settings.WorkspaceID, - BoardID: settings.BoardID, - RepositoryIDs: settings.RepositoryIDs, - UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), + UserID: settings.UserID, + WorkspaceID: settings.WorkspaceID, + BoardID: settings.BoardID, + RepositoryIDs: settings.RepositoryIDs, + InitialSetupComplete: settings.InitialSetupComplete, + UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go index 4add5b772c..473f45581e 100644 --- a/apps/backend/internal/user/handlers/handlers.go +++ b/apps/backend/internal/user/handlers/handlers.go @@ -64,9 +64,10 @@ func (h *Handlers) httpGetUserSettings(c *gin.Context) { } type httpUpdateUserSettingsRequest struct { - WorkspaceID *string `json:"workspace_id,omitempty"` - BoardID *string `json:"board_id,omitempty"` - RepositoryIDs *[]string `json:"repository_ids,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` + InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` } func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { @@ -76,9 +77,10 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { return } resp, err := h.controller.UpdateUserSettings(c.Request.Context(), dto.UpdateUserSettingsRequest{ - WorkspaceID: body.WorkspaceID, - BoardID: body.BoardID, - RepositoryIDs: body.RepositoryIDs, + WorkspaceID: body.WorkspaceID, + BoardID: body.BoardID, + RepositoryIDs: body.RepositoryIDs, + InitialSetupComplete: body.InitialSetupComplete, }) if err != nil { h.logger.Error("failed to update user settings", zap.Error(err)) @@ -97,9 +99,10 @@ func (h *Handlers) wsGetUser(ctx context.Context, msg *ws.Message) (*ws.Message, } type wsUpdateUserSettingsRequest struct { - WorkspaceID *string `json:"workspace_id,omitempty"` - BoardID *string `json:"board_id,omitempty"` - RepositoryIDs *[]string `json:"repository_ids,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + BoardID *string `json:"board_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` + InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` } func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -108,9 +111,10 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (* return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } resp, err := h.controller.UpdateUserSettings(ctx, dto.UpdateUserSettingsRequest{ - WorkspaceID: req.WorkspaceID, - BoardID: req.BoardID, - RepositoryIDs: req.RepositoryIDs, + WorkspaceID: req.WorkspaceID, + BoardID: req.BoardID, + RepositoryIDs: req.RepositoryIDs, + InitialSetupComplete: req.InitialSetupComplete, }) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil) diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 241f1a643f..4bf1637831 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -10,10 +10,11 @@ type User struct { } type UserSettings struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + InitialSetupComplete bool `json:"initial_setup_complete"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 4283d24f61..1124b8391b 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -23,9 +23,10 @@ type Service struct { } type UpdateUserSettingsRequest struct { - WorkspaceID *string - BoardID *string - RepositoryIDs *[]string + WorkspaceID *string + BoardID *string + RepositoryIDs *[]string + InitialSetupComplete *bool } func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { @@ -67,6 +68,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if req.RepositoryIDs != nil { settings.RepositoryIDs = *req.RepositoryIDs } + if req.InitialSetupComplete != nil { + settings.InitialSetupComplete = *req.InitialSetupComplete + } settings.UpdatedAt = time.Now().UTC() if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { return nil, err @@ -80,11 +84,12 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models return } data := map[string]interface{}{ - "user_id": settings.UserID, - "workspace_id": settings.WorkspaceID, - "board_id": settings.BoardID, - "repository_ids": settings.RepositoryIDs, - "updated_at": settings.UpdatedAt.Format(time.RFC3339), + "user_id": settings.UserID, + "workspace_id": settings.WorkspaceID, + "board_id": settings.BoardID, + "repository_ids": settings.RepositoryIDs, + "initial_setup_complete": settings.InitialSetupComplete, + "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { s.logger.Error("failed to publish user settings event", zap.Error(err)) diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 7bf323f603..fa49740112 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -5,10 +5,10 @@ import ( "database/sql" "encoding/json" "fmt" + _ "github.com/mattn/go-sqlite3" "os" "path/filepath" "time" - _ "github.com/mattn/go-sqlite3" "github.com/kandev/kandev/internal/user/models" ) @@ -187,9 +187,10 @@ func (r *SQLiteRepository) UpsertUserSettings(ctx context.Context, settings *mod settings.CreatedAt = settings.UpdatedAt } settingsPayload, err := json.Marshal(map[string]interface{}{ - "workspace_id": settings.WorkspaceID, - "board_id": settings.BoardID, - "repository_ids": settings.RepositoryIDs, + "workspace_id": settings.WorkspaceID, + "board_id": settings.BoardID, + "repository_ids": settings.RepositoryIDs, + "initial_setup_complete": settings.InitialSetupComplete, }) if err != nil { return err @@ -228,9 +229,10 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin return settings, nil } var payload struct { - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + InitialSetupComplete bool `json:"initial_setup_complete"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -238,5 +240,6 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.WorkspaceID = payload.WorkspaceID settings.BoardID = payload.BoardID settings.RepositoryIDs = payload.RepositoryIDs + settings.InitialSetupComplete = payload.InitialSetupComplete return settings, nil } diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 1a19f92791..19289d6642 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -8,6 +8,7 @@ import type { ListColumnsResponse, RepositoryDiscoveryResponse, ListRepositoriesResponse, + RepositoryBranchesResponse, ListRepositoryScriptsResponse, ListWorkspacesResponse, RepositoryPathValidationResponse, @@ -142,6 +143,16 @@ export async function listRepositoriesAction(workspaceId: string): Promise(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories`); } +export async function listLocalRepositoryBranchesAction( + workspaceId: string, + path: string +): Promise { + const params = `?path=${encodeURIComponent(path)}`; + return fetchJson( + `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/branches${params}` + ); +} + export async function discoverRepositoriesAction( workspaceId: string, root?: string diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 997e5b0336..c2163f8ca8 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -90,15 +90,18 @@ export function Combobox({ { - onValueChange(currentValue === value ? "" : currentValue) + onSelect={() => { + onValueChange(option.value === value ? "" : option.value) setOpen(false) }} + className="relative pr-7" > - {option.renderLabel ? option.renderLabel() : option.label} +
+ {option.renderLabel ? option.renderLabel() : option.label} +
diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index aebf979805..75c21f0083 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -18,16 +18,14 @@ import { Button } from '@kandev/ui/button'; import { Select, SelectContent, - SelectGroup, SelectItem, - SelectLabel, SelectTrigger, SelectValue, } from '@kandev/ui/select'; import { Combobox } from './combobox'; import { Badge } from '@kandev/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip'; -import type { Task } from '@/lib/types/http'; +import type { LocalRepository, Task } from '@/lib/types/http'; import { DEFAULT_LOCAL_ENVIRONMENT_KIND, DEFAULT_LOCAL_EXECUTOR_TYPE, @@ -44,6 +42,8 @@ import { SHORTCUTS } from '@/lib/keyboard/constants'; import { useKeyboardShortcutHandler } from '@/hooks/use-keyboard-shortcut'; import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; import { getWebSocketClient } from '@/lib/ws/connection'; +import { useToast } from '@/components/toast-provider'; +import { discoverRepositoriesAction, listLocalRepositoryBranchesAction } from '@/app/actions/workspaces'; interface TaskCreateDialogProps { open: boolean; @@ -71,6 +71,119 @@ interface TaskCreateDialogProps { submitLabel?: string; } +type RepositoryOption = { + value: string; + label: string; + renderLabel: () => React.ReactNode; +}; + +type RepositorySelectorProps = { + options: RepositoryOption[]; + value: string; + onValueChange: (value: string) => void; + disabled: boolean; + placeholder: string; + searchPlaceholder: string; + emptyMessage: string; +}; + +function RepositorySelector({ + options, + value, + onValueChange, + disabled, + placeholder, + searchPlaceholder, + emptyMessage, +}: RepositorySelectorProps) { + return ( + + ); +} + +type BranchOption = { + value: string; + label: string; + renderLabel: () => React.ReactNode; +}; + +type BranchSelectorProps = { + options: BranchOption[]; + value: string; + onValueChange: (value: string) => void; + disabled: boolean; + placeholder: string; + searchPlaceholder: string; + emptyMessage: string; +}; + +function BranchSelector({ + options, + value, + onValueChange, + disabled, + placeholder, + searchPlaceholder, + emptyMessage, +}: BranchSelectorProps) { + return ( + + ); +} + +type AgentSelectorProps = { + options: Array<{ value: string; label: string; renderLabel: () => React.ReactNode }>; + value: string; + onValueChange: (value: string) => void; + disabled: boolean; + placeholder: string; + triggerClassName?: string; +}; + +function AgentSelector({ + options, + value, + onValueChange, + disabled, + placeholder, + triggerClassName, +}: AgentSelectorProps) { + return ( + + ); +} + export function TaskCreateDialog({ open, onOpenChange, @@ -96,12 +209,20 @@ export function TaskCreateDialog({ const [environmentId, setEnvironmentId] = useState(''); const [executorId, setExecutorId] = useState(''); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const [discoveredRepositories, setDiscoveredRepositories] = useState([]); + const [discoveredRepoPath, setDiscoveredRepoPath] = useState(''); + const [selectedLocalRepo, setSelectedLocalRepo] = useState(null); + const [localBranches, setLocalBranches] = useState([]); + const [localBranchesLoading, setLocalBranchesLoading] = useState(false); + const [discoverReposLoading, setDiscoverReposLoading] = useState(false); + const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false); const workspaces = useAppStore((state) => state.workspaces.items); const agentProfiles = useAppStore((state) => state.agentProfiles.items); const executors = useAppStore((state) => state.executors.items); const environments = useAppStore((state) => state.environments.items); const settingsData = useAppStore((state) => state.settingsData); const descriptionRef = useRef(null); + const { toast } = useToast(); useSettingsData(open); const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open); const { branches, isLoading: branchesLoading } = useRepositoryBranches( @@ -131,16 +252,56 @@ export function TaskCreateDialog({ open, ]); + useEffect(() => { + if (!open) return; + setDiscoveredRepositories([]); + setDiscoveredRepoPath(''); + setSelectedLocalRepo(null); + setLocalBranches([]); + setDiscoverReposLoaded(false); + }, [open, workspaceId]); + const workspaceDefaults = workspaceId ? workspaces.find((workspace) => workspace.id === workspaceId) : null; useEffect(() => { if (!open || !workspaceId) return; - if (!repositoryId && repositories.length === 1) { + if (!repositoryId && !selectedLocalRepo && repositories.length === 1) { setRepositoryId(repositories[0].id); } - }, [open, repositories, repositoryId, workspaceId]); + }, [open, repositories, repositoryId, selectedLocalRepo, workspaceId]); + + useEffect(() => { + if (!open || !workspaceId) return; + if (repositoriesLoading) return; + if (discoverReposLoaded || discoverReposLoading) return; + setDiscoverReposLoading(true); + discoverRepositoriesAction(workspaceId) + .then((response) => { + setDiscoveredRepositories(response.repositories); + }) + .catch((error) => { + toast({ + title: 'Failed to discover repositories', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + setDiscoveredRepositories([]); + }) + .finally(() => { + setDiscoverReposLoading(false); + setDiscoverReposLoaded(true); + }); + }, [ + discoverReposLoaded, + discoverReposLoading, + open, + repositories.length, + repositoriesLoading, + toast, + workspaceId, + ]); useEffect(() => { if (!repositoryId) return; @@ -152,6 +313,89 @@ export function TaskCreateDialog({ } }, [branch, branches, repositoryId]); + const handleSelectLocalRepository = (path: string) => { + const selected = discoveredRepositories.find((repo) => repo.path === path) ?? null; + setDiscoveredRepoPath(path); + setSelectedLocalRepo(selected); + setRepositoryId(''); + setBranch(''); + setLocalBranches([]); + }; + + const hasRepositorySelection = Boolean(repositoryId || selectedLocalRepo); + const branchOptions = repositoryId + ? branches + : localBranches; + const normalizeRepoPath = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/g, ''); + const workspaceRepoPaths = new Set( + repositories + .map((repo) => repo.local_path) + .filter(Boolean) + .map((path) => normalizeRepoPath(path)) + ); + const localRepoOptions = discoveredRepositories.filter( + (repo) => !workspaceRepoPaths.has(normalizeRepoPath(repo.path)) + ); + const repositoryOptions: RepositoryOption[] = [ + ...repositories.map((repo) => ({ + value: repo.id, + label: repo.name, + renderLabel: () => ( + + {repo.name} + + {truncateRepoPath(repo.local_path)} + + + ), + })), + ...localRepoOptions.map((repo) => ({ + value: repo.path, + label: formatUserHomePath(repo.path), + renderLabel: () => ( + + {truncateRepoPath(repo.path)} + + ), + })), + ]; + + useEffect(() => { + if (!open || !workspaceId || !selectedLocalRepo) return; + setLocalBranchesLoading(true); + listLocalRepositoryBranchesAction(workspaceId, selectedLocalRepo.path) + .then((response) => { + setLocalBranches(response.branches); + }) + .catch((error) => { + toast({ + title: 'Failed to load branches', + description: error instanceof Error ? error.message : 'Request failed', + variant: 'error', + }); + setLocalBranches([]); + }) + .finally(() => { + setLocalBranchesLoading(false); + }); + }, [open, selectedLocalRepo, toast, workspaceId]); + + useEffect(() => { + if (repositoryId || localBranches.length === 0) return; + if (branch) return; + const preferredBranch = selectPreferredBranch(localBranches); + if (preferredBranch) { + setBranch(preferredBranch); + } + }, [branch, localBranches, repositoryId]); + useEffect(() => { if (!open || agentProfileId || agentProfiles.length === 0) return; if (!isSessionMode && !startAgent) return; @@ -254,7 +498,7 @@ export function TaskCreateDialog({ return; } if (!workspaceId || !boardId) return; - if (!repositoryId) return; + if (!repositoryId && !selectedLocalRepo) return; const columnId = editingTask?.columnId ?? defaultColumnId; if (!columnId) return; let targetColumnId = columnId; @@ -273,10 +517,23 @@ export function TaskCreateDialog({ column_id: targetColumnId, title: trimmedTitle, description: description.trim(), - repositories: repositoryId ? [{ - repository_id: repositoryId, - base_branch: branch || undefined, - }] : [], + repositories: repositoryId + ? [ + { + repository_id: repositoryId, + base_branch: branch || undefined, + }, + ] + : selectedLocalRepo + ? [ + { + repository_id: '', + base_branch: branch || undefined, + local_path: selectedLocalRepo.path, + default_branch: selectedLocalRepo.default_branch || undefined, + }, + ] + : [], state: targetState, }); console.log('[TaskCreateDialog] task created', { @@ -308,10 +565,10 @@ export function TaskCreateDialog({ setDescription(''); setRepositoryId(''); setBranch(''); - setStartAgent(!isEditMode); - setAgentProfileId(''); - setEnvironmentId(''); - setExecutorId(''); + setStartAgent(!isEditMode); + setAgentProfileId(''); + setEnvironmentId(''); + setExecutorId(''); onOpenChange(false); } }; @@ -379,60 +636,55 @@ export function TaskCreateDialog({ {!isSessionMode && (
- {repositories.length === 0 && !repositoriesLoading ? ( -
- No repositories found. - - Add repository - -
- ) : ( - ({ - value: repo.id, - label: repo.name, - renderLabel: () => ( - - {repo.name} - - {truncateRepoPath(repo.local_path)} - - - ), - }))} - value={repositoryId} + { - setRepositoryId(value); - setBranch(''); + const workspaceRepo = repositories.find((repo) => repo.id === value); + if (workspaceRepo) { + setRepositoryId(value); + setDiscoveredRepoPath(''); + setSelectedLocalRepo(null); + setLocalBranches([]); + setBranch(''); + return; + } + handleSelectLocalRepository(value); }} - placeholder={repositoriesLoading ? 'Loading repositories...' : 'Select repository'} + placeholder={ + !workspaceId + ? 'Select workspace first' + : repositoriesLoading || discoverReposLoading + ? 'Loading repositories...' + : 'Select repository' + } searchPlaceholder="Search repositories..." - emptyMessage="No repository found." - disabled={isEditMode || repositoriesLoading} - dropdownLabel="Repository" - className={isEditMode || repositoriesLoading ? undefined : 'cursor-pointer'} + emptyMessage={ + repositoriesLoading || discoverReposLoading + ? 'Loading repositories...' + : 'No repositories found.' + } + disabled={!workspaceId || repositoriesLoading || discoverReposLoading || isEditMode} /> - )} -
+
- { - const displayName = branchObj.type === 'remote' && branchObj.remote - ? `${branchObj.remote}/${branchObj.name}` - : branchObj.name; + { + const displayName = + branchObj.type === 'remote' && branchObj.remote + ? `${branchObj.remote}/${branchObj.name}` + : branchObj.name; // Use display name as unique value since it includes remote prefix return { value: displayName, label: displayName, renderLabel: () => ( - {displayName} + + {displayName} + - {branchObj.type} + {branchObj.type === 'local' ? 'local' : branchObj.remote || 'remote'} ), @@ -444,33 +696,57 @@ export function TaskCreateDialog({ setBranch(displayName); }} placeholder={ - !repositoryId + !hasRepositorySelection ? 'Select repository first' - : branchesLoading - ? 'Loading branches...' - : 'Select branch' + : repositoryId + ? branchesLoading + ? 'Loading branches...' + : 'Select branch' + : localBranchesLoading + ? 'Loading branches...' + : branchOptions.length > 0 + ? 'Select branch' + : 'No branches found' } searchPlaceholder="Search branches..." emptyMessage="No branch found." - disabled={isEditMode || !repositoryId || branchesLoading} - dropdownLabel="Base Branch" - className={isEditMode || !repositoryId || branchesLoading ? undefined : 'cursor-pointer'} + disabled={ + isEditMode || + !hasRepositorySelection || + (repositoryId && branchesLoading) || + (!repositoryId && (localBranchesLoading || branchOptions.length === 0)) + } />
- {startAgent && ( -
- {agentProfiles.length === 0 && !agentProfilesLoading ? ( -
- No agents found. - - Add agent - -
- ) : ( - + {agentProfiles.length === 0 && !agentProfilesLoading ? ( +
+ No agents found. + + Add agent + +
+ ) : ( + ({ value: profile.id, label: profile.label, + renderLabel: () => { + const parts = profile.label.split(' • '); + const agentLabel = parts[0] ?? profile.label; + const profileLabel = parts[1] ?? ''; + return ( + + {agentLabel} + {profileLabel ? ( + + {profileLabel} + + ) : null} + + ); + }, }))} value={agentProfileId} onValueChange={setAgentProfileId} @@ -481,36 +757,44 @@ export function TaskCreateDialog({ ? 'No agents available' : 'Select agent' } - searchPlaceholder="Search agents..." - emptyMessage="No agent found." - dropdownLabel="Agent profile" - className={agentProfilesLoading ? undefined : 'cursor-pointer'} + disabled={agentProfilesLoading} /> - )} -
- )} - + )} + + )} + )} {/* Agent Profile - shown in session mode */} {isSessionMode && (
- - + ({ + value: profile.id, + label: profile.label, + renderLabel: () => { + const parts = profile.label.split(' • '); + const agentLabel = parts[0] ?? profile.label; + const profileLabel = parts[1] ?? ''; + return ( + + {agentLabel} + {profileLabel ? ( + + {profileLabel} + + ) : null} + + ); + }, + }))} + value={agentProfileId} + onValueChange={setAgentProfileId} + placeholder={agentProfilesLoading ? 'Loading agent profiles...' : 'Select agent profile'} + disabled={agentProfilesLoading} + triggerClassName="w-[280px]" + />
{/* More Options Toggle */} @@ -541,34 +825,13 @@ export function TaskCreateDialog({ )} {showAdvancedSettings && isSessionMode && (
- {/* Executor and Environment in same row */} -
- {/* Executor */} -
- - -
- - {/* Environment */} +
+
+ + +
)} {showAdvancedSettings && !isSessionMode && startAgent && (
-
+
+
-
+
+
@@ -663,7 +939,7 @@ export function TaskCreateDialog({
{startAgent && ( - A git worktree will be created from the above branch. + A git worktree will be created from the base branch. )}
@@ -680,8 +956,9 @@ export function TaskCreateDialog({ isSessionMode ? !description.trim() || !agentProfileId : !title.trim() || - (!isEditMode && !repositoryId) || - (startAgent && !agentProfileId) + !description.trim() || + (!isEditMode && !repositoryId && !selectedLocalRepo) || + (startAgent && !agentProfileId) } > {isSessionMode ? 'Create Session' : submitLabel} diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts index 2ee4849f27..e9c24185c0 100644 --- a/apps/web/lib/http/index.ts +++ b/apps/web/lib/http/index.ts @@ -96,6 +96,9 @@ export async function createTask( repositories?: Array<{ repository_id: string; base_branch?: string; + local_path?: string; + name?: string; + default_branch?: string; }>; state?: Task['state']; }, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index dd72d92025..2cb56cfb9b 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -221,6 +221,7 @@ export type UserSettingsUpdatedPayload = { workspace_id: string; board_id: string; repository_ids: string[]; + initial_setup_complete?: boolean; updated_at?: string; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 4bfb9fb23c..04c6822e85 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -152,6 +152,7 @@ export type UserSettings = { workspace_id: string; board_id: string; repository_ids: string[]; + initial_setup_complete?: boolean; updated_at: string; }; diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index 2cfbc6c191..a7d1f0b20b 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -90,7 +90,7 @@ export function formatUserHomePath(path: string): string { /** * Truncate a repository path for display, favoring the last segments. */ -export function truncateRepoPath(path: string, maxLength = 28): string { +export function truncateRepoPath(path: string, maxLength = 34): string { const displayPath = formatUserHomePath(path); if (displayPath.length <= maxLength) return displayPath; const normalizedPath = displayPath.replace(/\\/g, '/'); @@ -99,8 +99,11 @@ export function truncateRepoPath(path: string, maxLength = 28): string { const prefix = hasHomePrefix ? '~/' : hasRootPrefix ? '/' : ''; const parts = normalizedPath.replace(/^~\//, '').replace(/^\//, '').split('/').filter(Boolean); if (parts.length === 0) return displayPath; + const lastThree = parts.slice(-3).join('/'); + let result = `${prefix}.../${lastThree}`; + if (result.length <= maxLength) return result; const lastTwo = parts.slice(-2).join('/'); - let result = `${prefix}.../${lastTwo}`; + result = `${prefix}.../${lastTwo}`; if (result.length <= maxLength) return result; const lastOne = parts.slice(-1).join('/'); result = `${prefix}.../${lastOne}`;