From 19ea7ddc4e8d21e19de6373a32f5503d10a8e42d Mon Sep 17 00:00:00 2001 From: jcfs Date: Mon, 12 Jan 2026 15:19:11 +0000 Subject: [PATCH] feat: expose worktree path and branch in task API and UI - Add WorktreePath and WorktreeBranch fields to TaskDTO - Add WorktreeLookup interface to TaskController for enriching task responses - Add GetWorktreeInfo method to worktree.Manager implementing WorktreeLookup - Wire worktree manager to task controller in main.go - Add worktree_path and worktree_branch to frontend Task type - Display worktree path with copy button in TaskTopBar when worktree exists - Show worktree branch in tooltip for easy reference --- apps/backend/cmd/kandev/main.go | 35 ++++++++++++++++ .../internal/agent/worktree/manager.go | 11 +++++ apps/backend/internal/task/controller/task.go | 32 +++++++++++++-- apps/backend/internal/task/dto/dto.go | 3 ++ apps/web/app/task/[id]/page-client.tsx | 2 + apps/web/components/task/task-top-bar.tsx | 41 +++++++++++++++++++ apps/web/lib/types/http.ts | 3 ++ 7 files changed, 124 insertions(+), 3 deletions(-) diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 6c0beb1c31..52da8076e5 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -33,6 +33,7 @@ import ( agenthandlers "github.com/kandev/kandev/internal/agent/handlers" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/worktree" // Orchestrator packages "github.com/kandev/kandev/internal/orchestrator" @@ -178,6 +179,37 @@ func main() { log.Info("Agent Manager disabled (no Docker)") } + // ============================================ + // WORKTREE MANAGER + // ============================================ + log.Info("Initializing Worktree Manager...") + + // Create worktree store (uses same database as tasks) + // Type assert to get the *sql.DB from the SQLite repository + var worktreeMgr *worktree.Manager + if sqliteRepo, ok := taskRepo.(*repository.SQLiteRepository); ok { + worktreeStore, err := worktree.NewSQLiteStore(sqliteRepo.DB()) + if err != nil { + log.Fatal("Failed to initialize worktree store", zap.Error(err)) + } + + // Create worktree manager with config from common config + worktreeMgr, err = worktree.NewManager(worktree.Config{ + Enabled: cfg.Worktree.Enabled, + BasePath: cfg.Worktree.BasePath, + MaxPerRepo: cfg.Worktree.MaxPerRepo, + BranchPrefix: "kandev/", // Default branch prefix + }, worktreeStore, log) + if err != nil { + log.Fatal("Failed to initialize worktree manager", zap.Error(err)) + } + log.Info("Worktree Manager initialized", + zap.Bool("enabled", cfg.Worktree.Enabled), + zap.String("base_path", cfg.Worktree.BasePath)) + } else { + log.Warn("Worktree Manager disabled (requires SQLite repository)") + } + // ============================================ // ORCHESTRATOR // ============================================ @@ -212,6 +244,9 @@ func main() { workspaceController := taskcontroller.NewWorkspaceController(taskSvc) boardController := taskcontroller.NewBoardController(taskSvc) taskController := taskcontroller.NewTaskController(taskSvc) + if worktreeMgr != nil { + taskController.SetWorktreeLookup(worktreeMgr) // Enable worktree info in task responses + } repositoryController := taskcontroller.NewRepositoryController(taskSvc) // Create orchestrator controller and handlers (Pattern A) diff --git a/apps/backend/internal/agent/worktree/manager.go b/apps/backend/internal/agent/worktree/manager.go index 630e763824..baa8d09a2d 100644 --- a/apps/backend/internal/agent/worktree/manager.go +++ b/apps/backend/internal/agent/worktree/manager.go @@ -90,6 +90,17 @@ func (m *Manager) IsEnabled() bool { return m.config.Enabled } +// GetWorktreeInfo returns worktree path and branch for a task. +// Returns nil values if no worktree exists for the task. +// This method implements the WorktreeLookup interface for task enrichment. +func (m *Manager) GetWorktreeInfo(ctx context.Context, taskID string) (path, branch *string) { + wt, err := m.GetByTaskID(ctx, taskID) + if err != nil || wt == nil { + return nil, nil + } + return &wt.Path, &wt.Branch +} + // Create creates a new worktree for a task. // If a worktree already exists for the task, it returns the existing one. func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, error) { diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 2edbd4e42b..869c5e042c 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -7,14 +7,36 @@ import ( "github.com/kandev/kandev/internal/task/service" ) +// WorktreeLookup provides worktree information for tasks. +type WorktreeLookup interface { + // GetWorktreeInfo returns worktree path and branch for a task, or nil if no worktree exists. + GetWorktreeInfo(ctx context.Context, taskID string) (path, branch *string) +} + type TaskController struct { - service *service.Service + service *service.Service + worktreeLookup WorktreeLookup } func NewTaskController(svc *service.Service) *TaskController { return &TaskController{service: svc} } +// SetWorktreeLookup sets the worktree lookup for enriching task responses. +func (c *TaskController) SetWorktreeLookup(lookup WorktreeLookup) { + c.worktreeLookup = lookup +} + +// enrichWithWorktree adds worktree info to a TaskDTO if available. +func (c *TaskController) enrichWithWorktree(ctx context.Context, task *dto.TaskDTO) { + if c.worktreeLookup == nil { + return + } + path, branch := c.worktreeLookup.GetWorktreeInfo(ctx, task.ID) + task.WorktreePath = path + task.WorktreeBranch = branch +} + func (c *TaskController) ListTasks(ctx context.Context, req dto.ListTasksRequest) (dto.ListTasksResponse, error) { tasks, err := c.service.ListTasks(ctx, req.BoardID) if err != nil { @@ -25,7 +47,9 @@ func (c *TaskController) ListTasks(ctx context.Context, req dto.ListTasksRequest Total: len(tasks), } for _, task := range tasks { - resp.Tasks = append(resp.Tasks, dto.FromTask(task)) + taskDTO := dto.FromTask(task) + c.enrichWithWorktree(ctx, &taskDTO) + resp.Tasks = append(resp.Tasks, taskDTO) } return resp, nil } @@ -35,7 +59,9 @@ func (c *TaskController) GetTask(ctx context.Context, req dto.GetTaskRequest) (d if err != nil { return dto.TaskDTO{}, err } - return dto.FromTask(task), nil + taskDTO := dto.FromTask(task) + c.enrichWithWorktree(ctx, &taskDTO) + return taskDTO, nil } func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskRequest) (dto.TaskDTO, error) { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 82e1922792..6f66366388 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -81,6 +81,9 @@ type TaskDTO struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Metadata map[string]interface{} `json:"metadata,omitempty"` + // Worktree information (populated if worktree exists for this task) + WorktreePath *string `json:"worktree_path,omitempty"` + WorktreeBranch *string `json:"worktree_branch,omitempty"` } type BoardSnapshotDTO struct { diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index d1b5fed37a..fa6faee0b8 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -205,6 +205,8 @@ export default function TaskPage({ task }: TaskPageClientProps) { onStopAgent={handleStopAgent} isAgentRunning={isAgentRunning} isAgentLoading={isAgentLoading} + worktreePath={task?.worktree_path} + worktreeBranch={task?.worktree_branch} />
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 803c59afc5..2b11a6810e 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -10,6 +10,7 @@ import { IconChevronRight, IconCopy, IconEye, + IconFolder, IconGitBranch, IconGitFork, IconGitMerge, @@ -34,6 +35,8 @@ type TaskTopBarProps = { onStopAgent?: () => void; isAgentRunning?: boolean; isAgentLoading?: boolean; + worktreePath?: string | null; + worktreeBranch?: string | null; }; const TaskTopBar = memo(function TaskTopBar({ @@ -45,6 +48,8 @@ const TaskTopBar = memo(function TaskTopBar({ onStopAgent, isAgentRunning = false, isAgentLoading = false, + worktreePath, + worktreeBranch, }: TaskTopBarProps) { const [branchName, setBranchName] = useState('feature/agent-ui'); const [isEditingBranch, setIsEditingBranch] = useState(false); @@ -165,6 +170,42 @@ const TaskTopBar = memo(function TaskTopBar({ Lines changed + {/* Worktree Info */} + {worktreePath && ( +
+ + +
+ + + {worktreePath} + + +
+
+ +
+
Worktree Path
+
{worktreePath}
+ {worktreeBranch && ( +
+ Branch: {worktreeBranch} +
+ )} +
+
+
+
+ )}
{/* Start/Stop Agent Button */} diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 76d5a57e51..3607020165 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -83,6 +83,9 @@ export type Task = { created_at: string; updated_at: string; metadata?: Record | null; + // Worktree information (present if agent has created a worktree for this task) + worktree_path?: string | null; + worktree_branch?: string | null; }; export type BoardSnapshot = {