feat: improve onboarding and task setup (#21)

This commit is contained in:
Carlos Florêncio
2026-01-16 15:51:26 +00:00
committed by GitHub
parent 36e169d52f
commit 2b38197b31
22 changed files with 862 additions and 298 deletions
+30
View File
@@ -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
@@ -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 {
@@ -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 {
+23 -17
View File
@@ -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,
})
}
+25 -17
View File
@@ -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 {
@@ -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"`
@@ -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,
})
}
}
@@ -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 {
+89 -32
View File
@@ -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 {
@@ -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
+17 -14
View File
@@ -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),
}
}
+16 -12
View File
@@ -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)
+7 -6
View File
@@ -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"`
}
+13 -8
View File
@@ -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))
+10 -7
View File
@@ -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
}
+11
View File
@@ -8,6 +8,7 @@ import type {
ListColumnsResponse,
RepositoryDiscoveryResponse,
ListRepositoriesResponse,
RepositoryBranchesResponse,
ListRepositoryScriptsResponse,
ListWorkspacesResponse,
RepositoryPathValidationResponse,
@@ -142,6 +143,16 @@ export async function listRepositoriesAction(workspaceId: string): Promise<ListR
return fetchJson<ListRepositoriesResponse>(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories`);
}
export async function listLocalRepositoryBranchesAction(
workspaceId: string,
path: string
): Promise<RepositoryBranchesResponse> {
const params = `?path=${encodeURIComponent(path)}`;
return fetchJson<RepositoryBranchesResponse>(
`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/branches${params}`
);
}
export async function discoverRepositoriesAction(
workspaceId: string,
root?: string
+7 -4
View File
@@ -90,15 +90,18 @@ export function Combobox({
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onValueChange(currentValue === value ? "" : currentValue)
onSelect={() => {
onValueChange(option.value === value ? "" : option.value)
setOpen(false)
}}
className="relative pr-7"
>
{option.renderLabel ? option.renderLabel() : option.label}
<div className="flex min-w-0 flex-1 items-center">
{option.renderLabel ? option.renderLabel() : option.label}
</div>
<IconCheck
className={cn(
"ml-auto h-4 w-4",
"absolute right-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
+418 -141
View File
@@ -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 (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder={searchPlaceholder}
emptyMessage={emptyMessage}
disabled={disabled}
dropdownLabel="Repository"
className={disabled ? undefined : 'cursor-pointer'}
/>
);
}
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 (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder={searchPlaceholder}
emptyMessage={emptyMessage}
disabled={disabled}
dropdownLabel="Base Branch"
className={disabled ? undefined : 'cursor-pointer'}
/>
);
}
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 (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder="Search agents..."
emptyMessage="No agent found."
disabled={disabled}
dropdownLabel="Agent profile"
className={disabled ? undefined : 'cursor-pointer'}
triggerClassName={triggerClassName}
/>
);
}
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<LocalRepository[]>([]);
const [discoveredRepoPath, setDiscoveredRepoPath] = useState('');
const [selectedLocalRepo, setSelectedLocalRepo] = useState<LocalRepository | null>(null);
const [localBranches, setLocalBranches] = useState<typeof branches>([]);
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<HTMLTextAreaElement | null>(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: () => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0">{repo.name}</span>
<Badge
variant="secondary"
className="text-xs text-muted-foreground max-w-[220px] min-w-0 truncate ml-auto"
title={formatUserHomePath(repo.local_path)}
>
{truncateRepoPath(repo.local_path)}
</Badge>
</span>
),
})),
...localRepoOptions.map((repo) => ({
value: repo.path,
label: formatUserHomePath(repo.path),
renderLabel: () => (
<span
className="flex min-w-0 flex-1 items-center truncate"
title={formatUserHomePath(repo.path)}
>
{truncateRepoPath(repo.path)}
</span>
),
})),
];
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 && (
<div className="grid gap-4 md:grid-cols-3">
<div>
{repositories.length === 0 && !repositoriesLoading ? (
<div className="flex items-center justify-center h-10 px-3 py-2 text-sm border border-input rounded-md bg-background">
<span className="text-muted-foreground mr-2">No repositories found.</span>
<Link href="/settings/workspace" className="text-primary hover:underline">
Add repository
</Link>
</div>
) : (
<Combobox
options={repositories.map((repo) => ({
value: repo.id,
label: repo.name,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0">{repo.name}</span>
<Badge
variant="secondary"
className="text-xs text-muted-foreground max-w-[220px] min-w-0 truncate ml-auto"
title={formatUserHomePath(repo.local_path)}
>
{truncateRepoPath(repo.local_path)}
</Badge>
</span>
),
}))}
value={repositoryId}
<RepositorySelector
options={repositoryOptions}
value={repositoryId || discoveredRepoPath}
onValueChange={(value) => {
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}
/>
)}
</div>
</div>
<div>
<Combobox
options={branches.map((branchObj) => {
const displayName = branchObj.type === 'remote' && branchObj.remote
? `${branchObj.remote}/${branchObj.name}`
: branchObj.name;
<BranchSelector
options={branchOptions.map((branchObj) => {
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: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate">{displayName}</span>
<span className="truncate" title={displayName}>
{displayName}
</span>
<Badge variant={branchObj.type === 'local' ? 'default' : 'secondary'} className="text-xs">
{branchObj.type}
{branchObj.type === 'local' ? 'local' : branchObj.remote || 'remote'}
</Badge>
</span>
),
@@ -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))
}
/>
</div>
{startAgent && (
<div>
{agentProfiles.length === 0 && !agentProfilesLoading ? (
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
<span>No agents found.</span>
<Link href="/settings/agents" className="text-primary hover:underline">
Add agent
</Link>
</div>
) : (
<Combobox
{startAgent && (
<div>
{agentProfiles.length === 0 && !agentProfilesLoading ? (
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
<span>No agents found.</span>
<Link href="/settings/agents" className="text-primary hover:underline">
Add agent
</Link>
</div>
) : (
<AgentSelector
options={agentProfiles.map((profile) => ({
value: profile.id,
label: profile.label,
renderLabel: () => {
const parts = profile.label.split(' • ');
const agentLabel = parts[0] ?? profile.label;
const profileLabel = parts[1] ?? '';
return (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate">{agentLabel}</span>
{profileLabel ? (
<Badge variant="secondary" className="text-xs">
{profileLabel}
</Badge>
) : null}
</span>
);
},
}))}
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}
/>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Agent Profile - shown in session mode */}
{isSessionMode && (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Label htmlFor="agent-profile" className="text-sm whitespace-nowrap">
Agent Profile
</Label>
<Select value={agentProfileId} onValueChange={setAgentProfileId} required>
<SelectTrigger id="agent-profile" className="w-[280px]">
<SelectValue placeholder={agentProfilesLoading ? 'Loading agent profiles...' : 'Select agent profile'} />
</SelectTrigger>
<SelectContent>
{agentProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
<AgentSelector
options={agentProfiles.map((profile) => ({
value: profile.id,
label: profile.label,
renderLabel: () => {
const parts = profile.label.split(' • ');
const agentLabel = parts[0] ?? profile.label;
const profileLabel = parts[1] ?? '';
return (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate">{agentLabel}</span>
{profileLabel ? (
<Badge variant="secondary" className="text-xs">
{profileLabel}
</Badge>
) : null}
</span>
);
},
}))}
value={agentProfileId}
onValueChange={setAgentProfileId}
placeholder={agentProfilesLoading ? 'Loading agent profiles...' : 'Select agent profile'}
disabled={agentProfilesLoading}
triggerClassName="w-[280px]"
/>
</div>
{/* More Options Toggle */}
@@ -541,34 +825,13 @@ export function TaskCreateDialog({
)}
{showAdvancedSettings && isSessionMode && (
<div className="pt-2 border-t">
{/* Executor and Environment in same row */}
<div className="flex items-center gap-6">
{/* Executor */}
<div className="flex items-center gap-3">
<Label htmlFor="executor" className="text-sm whitespace-nowrap">
Executor
</Label>
<Select value={executorId} onValueChange={setExecutorId}>
<SelectTrigger id="executor" className="w-[200px]">
<SelectValue placeholder={executorsLoading ? 'Loading executors...' : 'Select executor'} />
</SelectTrigger>
<SelectContent>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Environment */}
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center gap-3">
<Label htmlFor="environment" className="text-sm whitespace-nowrap">
Environment
</Label>
<Select value={environmentId} onValueChange={setEnvironmentId}>
<SelectTrigger id="environment" className="w-[200px]">
<SelectTrigger id="environment" className="w-full">
<SelectValue placeholder={environmentsLoading ? 'Loading environments...' : 'Select environment'} />
</SelectTrigger>
<SelectContent>
@@ -580,12 +843,30 @@ export function TaskCreateDialog({
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="executor" className="text-sm whitespace-nowrap">
Executor
</Label>
<Select value={executorId} onValueChange={setExecutorId}>
<SelectTrigger id="executor" className="w-full">
<SelectValue placeholder={executorsLoading ? 'Loading executors...' : 'Select executor'} />
</SelectTrigger>
<SelectContent>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{showAdvancedSettings && !isSessionMode && startAgent && (
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="flex items-center gap-3">
<Label className="text-sm whitespace-nowrap">Environment</Label>
<Select value={environmentId} onValueChange={setEnvironmentId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
@@ -599,18 +880,16 @@ export function TaskCreateDialog({
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Environment</SelectLabel>
{environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectGroup>
{environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<div className="flex items-center gap-3">
<Label className="text-sm whitespace-nowrap">Executor</Label>
<Select value={executorId} onValueChange={setExecutorId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
@@ -624,14 +903,11 @@ export function TaskCreateDialog({
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Executor</SelectLabel>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectGroup>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -663,7 +939,7 @@ export function TaskCreateDialog({
</div>
{startAgent && (
<span className="text-xs text-muted-foreground">
A git worktree will be created from the above branch.
A git worktree will be created from the base branch.
</span>
)}
</div>
@@ -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}
+3
View File
@@ -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'];
},
+1
View File
@@ -221,6 +221,7 @@ export type UserSettingsUpdatedPayload = {
workspace_id: string;
board_id: string;
repository_ids: string[];
initial_setup_complete?: boolean;
updated_at?: string;
};
+1
View File
@@ -152,6 +152,7 @@ export type UserSettings = {
workspace_id: string;
board_id: string;
repository_ids: string[];
initial_setup_complete?: boolean;
updated_at: string;
};
+5 -2
View File
@@ -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}`;