diff --git a/apps/backend/internal/editors/controller/controller.go b/apps/backend/internal/editors/controller/controller.go
index d187042815..f886739e11 100644
--- a/apps/backend/internal/editors/controller/controller.go
+++ b/apps/backend/internal/editors/controller/controller.go
@@ -63,6 +63,7 @@ func (c *Controller) OpenSessionEditor(ctx context.Context, sessionID string, re
FilePath: req.FilePath,
Line: req.Line,
Column: req.Column,
+ WorktreeID: req.WorktreeID,
})
if err != nil {
return dto.OpenEditorResponse{}, err
@@ -70,8 +71,8 @@ func (c *Controller) OpenSessionEditor(ctx context.Context, sessionID string, re
return dto.OpenEditorResponse{URL: url}, nil
}
-func (c *Controller) OpenFolder(ctx context.Context, sessionID string) (dto.OpenFolderResponse, error) {
- if err := c.service.OpenFolder(ctx, sessionID); err != nil {
+func (c *Controller) OpenFolder(ctx context.Context, sessionID string, req dto.OpenFolderRequest) (dto.OpenFolderResponse, error) {
+ if err := c.service.OpenFolder(ctx, sessionID, req.WorktreeID); err != nil {
return dto.OpenFolderResponse{Success: false}, err
}
return dto.OpenFolderResponse{Success: true}, nil
diff --git a/apps/backend/internal/editors/dto/dto.go b/apps/backend/internal/editors/dto/dto.go
index d95e858cb1..2de747bb05 100644
--- a/apps/backend/internal/editors/dto/dto.go
+++ b/apps/backend/internal/editors/dto/dto.go
@@ -31,6 +31,9 @@ type OpenEditorRequest struct {
FilePath string `json:"file_path,omitempty"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
+ // WorktreeID selects which of the session's worktrees to open
+ // (multi-repo sessions); empty falls back to the first worktree.
+ WorktreeID string `json:"worktree_id,omitempty"`
}
type OpenEditorResponse struct {
@@ -70,6 +73,12 @@ type UpdateEditorRequest struct {
Enabled *bool `json:"enabled,omitempty"`
}
+type OpenFolderRequest struct {
+ // WorktreeID selects which of the session's worktrees to open
+ // (multi-repo sessions); empty falls back to the first worktree.
+ WorktreeID string `json:"worktree_id,omitempty"`
+}
+
type OpenFolderResponse struct {
Success bool `json:"success"`
}
diff --git a/apps/backend/internal/editors/handlers/handlers.go b/apps/backend/internal/editors/handlers/handlers.go
index 2ccb98516e..c3326ecb2d 100644
--- a/apps/backend/internal/editors/handlers/handlers.go
+++ b/apps/backend/internal/editors/handlers/handlers.go
@@ -1,6 +1,8 @@
package handlers
import (
+ "errors"
+ "io"
"net/http"
"github.com/gin-gonic/gin"
@@ -9,8 +11,24 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/editors/controller"
"github.com/kandev/kandev/internal/editors/dto"
+ "github.com/kandev/kandev/internal/editors/service"
)
+// serviceErrorStatus maps known service errors to HTTP status codes so
+// client mistakes (bad worktree_id, unknown editor) don't surface as 500s.
+func serviceErrorStatus(err error) int {
+ switch {
+ case errors.Is(err, service.ErrEditorNotFound), errors.Is(err, service.ErrWorkspaceNotFound):
+ return http.StatusNotFound
+ case errors.Is(err, service.ErrEditorConfigInvalid):
+ return http.StatusBadRequest
+ case errors.Is(err, service.ErrEditorUnavailable):
+ return http.StatusConflict
+ default:
+ return http.StatusInternalServerError
+ }
+}
+
type Handlers struct {
controller *controller.Controller
logger *logger.Logger
@@ -53,7 +71,7 @@ func (h *Handlers) httpOpenSessionEditor(c *gin.Context) {
resp, err := h.controller.OpenSessionEditor(c.Request.Context(), c.Param("id"), req)
if err != nil {
h.logger.Error("failed to open editor", zap.Error(err))
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open editor"})
+ c.JSON(serviceErrorStatus(err), gin.H{"error": "failed to open editor"})
return
}
c.JSON(http.StatusOK, resp)
@@ -99,10 +117,18 @@ func (h *Handlers) httpDeleteEditor(c *gin.Context) {
}
func (h *Handlers) httpOpenFolder(c *gin.Context) {
- resp, err := h.controller.OpenFolder(c.Request.Context(), c.Param("id"))
+ var req dto.OpenFolderRequest
+ // The body is optional for backward compatibility; an empty body decodes
+ // as EOF and keeps the zero value. Checking ContentLength instead would
+ // silently drop the payload of chunked requests (ContentLength == -1).
+ if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
+ return
+ }
+ resp, err := h.controller.OpenFolder(c.Request.Context(), c.Param("id"), req)
if err != nil {
h.logger.Error("failed to open folder", zap.Error(err))
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open folder"})
+ c.JSON(serviceErrorStatus(err), gin.H{"error": "failed to open folder"})
return
}
c.JSON(http.StatusOK, resp)
diff --git a/apps/backend/internal/editors/handlers/handlers_test.go b/apps/backend/internal/editors/handlers/handlers_test.go
new file mode 100644
index 0000000000..40b0c376a3
--- /dev/null
+++ b/apps/backend/internal/editors/handlers/handlers_test.go
@@ -0,0 +1,32 @@
+package handlers
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/kandev/kandev/internal/editors/service"
+)
+
+func TestServiceErrorStatus(t *testing.T) {
+ tests := []struct {
+ err error
+ expected int
+ }{
+ {service.ErrWorkspaceNotFound, http.StatusNotFound},
+ {service.ErrEditorNotFound, http.StatusNotFound},
+ {service.ErrEditorConfigInvalid, http.StatusBadRequest},
+ {service.ErrEditorUnavailable, http.StatusConflict},
+ {fmt.Errorf("wrapped: %w", service.ErrWorkspaceNotFound), http.StatusNotFound},
+ {errors.New("something else"), http.StatusInternalServerError},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.err.Error(), func(t *testing.T) {
+ if got := serviceErrorStatus(tt.err); got != tt.expected {
+ t.Errorf("serviceErrorStatus(%v) = %d, want %d", tt.err, got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go
index e7a65f2ee9..c2f2cebd05 100644
--- a/apps/backend/internal/editors/service/service.go
+++ b/apps/backend/internal/editors/service/service.go
@@ -72,6 +72,7 @@ type OpenEditorInput struct {
FilePath string
Line int
Column int
+ WorktreeID string
}
func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string, error) {
@@ -82,7 +83,7 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string
if err != nil {
return "", err
}
- worktreePath, err := s.resolveSessionPath(ctx, session)
+ worktreePath, err := s.resolveSessionPath(ctx, session, input.WorktreeID)
if err != nil {
return "", err
}
@@ -183,7 +184,7 @@ func openBuiltinEditor(editor *models.Editor, absPath string, line, column int)
return "", nil
}
-func (s *Service) OpenFolder(ctx context.Context, sessionID string) error {
+func (s *Service) OpenFolder(ctx context.Context, sessionID, worktreeID string) error {
if sessionID == "" {
return ErrEditorConfigInvalid
}
@@ -191,7 +192,7 @@ func (s *Service) OpenFolder(ctx context.Context, sessionID string) error {
if err != nil {
return err
}
- worktreePath, err := s.resolveSessionPath(ctx, session)
+ worktreePath, err := s.resolveSessionPath(ctx, session, worktreeID)
if err != nil {
return err
}
@@ -250,10 +251,13 @@ func (s *Service) resolveEditor(ctx context.Context, editorID, editorType, fallb
return editor, nil
}
-func (s *Service) resolveSessionPath(ctx context.Context, session *taskmodels.TaskSession) (string, error) {
+func (s *Service) resolveSessionPath(ctx context.Context, session *taskmodels.TaskSession, worktreeID string) (string, error) {
if session == nil {
return "", ErrWorkspaceNotFound
}
+ if worktreeID != "" {
+ return findSessionWorktreePath(session, worktreeID)
+ }
if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" {
return session.Worktrees[0].WorktreePath, nil
}
@@ -269,6 +273,22 @@ func (s *Service) resolveSessionPath(ctx context.Context, session *taskmodels.Ta
return "", ErrWorkspaceNotFound
}
+// findSessionWorktreePath returns the path of the session worktree matching
+// worktreeID. It matches both the worktree ID and the session-worktree
+// association ID, since clients may hold either.
+func findSessionWorktreePath(session *taskmodels.TaskSession, worktreeID string) (string, error) {
+ for _, worktree := range session.Worktrees {
+ if worktree == nil || (worktree.WorktreeID != worktreeID && worktree.ID != worktreeID) {
+ continue
+ }
+ if worktree.WorktreePath == "" {
+ return "", ErrWorkspaceNotFound
+ }
+ return worktree.WorktreePath, nil
+ }
+ return "", ErrWorkspaceNotFound
+}
+
func (s *Service) resolveFilePath(worktreePath, filePath string) (string, error) {
if worktreePath == "" {
return "", ErrWorkspaceNotFound
diff --git a/apps/backend/internal/editors/service/service_test.go b/apps/backend/internal/editors/service/service_test.go
index 6f495f3073..f32aa950a5 100644
--- a/apps/backend/internal/editors/service/service_test.go
+++ b/apps/backend/internal/editors/service/service_test.go
@@ -9,7 +9,7 @@ import (
func TestOpenFolder_EmptySessionID(t *testing.T) {
svc := &Service{}
- err := svc.OpenFolder(context.Background(), "")
+ err := svc.OpenFolder(context.Background(), "", "")
if err != ErrEditorConfigInvalid {
t.Errorf("expected ErrEditorConfigInvalid, got %v", err)
}
@@ -17,7 +17,7 @@ func TestOpenFolder_EmptySessionID(t *testing.T) {
func TestResolveSessionPath_NilSession(t *testing.T) {
svc := &Service{}
- _, err := svc.resolveSessionPath(context.Background(), nil)
+ _, err := svc.resolveSessionPath(context.Background(), nil, "")
if err != ErrWorkspaceNotFound {
t.Errorf("expected ErrWorkspaceNotFound, got %v", err)
}
@@ -31,7 +31,7 @@ func TestResolveSessionPath_WithWorktree(t *testing.T) {
{WorktreePath: "/path/to/worktree"},
},
}
- path, err := svc.resolveSessionPath(context.Background(), session)
+ path, err := svc.resolveSessionPath(context.Background(), session, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -48,7 +48,57 @@ func TestResolveSessionPath_EmptyWorktreePath(t *testing.T) {
{WorktreePath: ""},
},
}
- _, err := svc.resolveSessionPath(context.Background(), session)
+ _, err := svc.resolveSessionPath(context.Background(), session, "")
+ if err != ErrWorkspaceNotFound {
+ t.Errorf("expected ErrWorkspaceNotFound, got %v", err)
+ }
+}
+
+func TestResolveSessionPath_SelectsWorktreeByID(t *testing.T) {
+ svc := &Service{}
+ session := &taskmodels.TaskSession{
+ ID: "session-1",
+ Worktrees: []*taskmodels.TaskSessionWorktree{
+ {ID: "assoc-1", WorktreeID: "wt-1", WorktreePath: "/path/to/repo-a"},
+ {ID: "assoc-2", WorktreeID: "wt-2", WorktreePath: "/path/to/repo-b"},
+ },
+ }
+
+ tests := []struct {
+ name string
+ worktreeID string
+ expected string
+ expectErr error
+ }{
+ {name: "by worktree id", worktreeID: "wt-2", expected: "/path/to/repo-b"},
+ {name: "by association id", worktreeID: "assoc-2", expected: "/path/to/repo-b"},
+ {name: "empty falls back to first", worktreeID: "", expected: "/path/to/repo-a"},
+ {name: "unknown id", worktreeID: "wt-missing", expectErr: ErrWorkspaceNotFound},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ path, err := svc.resolveSessionPath(context.Background(), session, tt.worktreeID)
+ if err != tt.expectErr {
+ t.Fatalf("expected error %v, got %v", tt.expectErr, err)
+ }
+ if path != tt.expected {
+ t.Errorf("expected %s, got %s", tt.expected, path)
+ }
+ })
+ }
+}
+
+func TestResolveSessionPath_SelectedWorktreeEmptyPath(t *testing.T) {
+ svc := &Service{}
+ session := &taskmodels.TaskSession{
+ ID: "session-1",
+ Worktrees: []*taskmodels.TaskSessionWorktree{
+ {ID: "assoc-1", WorktreeID: "wt-1", WorktreePath: "/path/to/repo-a"},
+ {ID: "assoc-2", WorktreeID: "wt-2", WorktreePath: ""},
+ },
+ }
+ _, err := svc.resolveSessionPath(context.Background(), session, "wt-2")
if err != ErrWorkspaceNotFound {
t.Errorf("expected ErrWorkspaceNotFound, got %v", err)
}
diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go
index 5345ade4ab..54044d414c 100644
--- a/apps/backend/internal/task/dto/converters_test.go
+++ b/apps/backend/internal/task/dto/converters_test.go
@@ -3,6 +3,7 @@ package dto
import (
"testing"
+ "github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
@@ -56,3 +57,30 @@ func TestFromWorkflowStep_PreservesWIPFields(t *testing.T) {
t.Fatalf("PullFromStepID = %q, want queue-step", got.PullFromStepID)
}
}
+
+func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) {
+ session := &models.TaskSession{
+ ID: "session-1",
+ TaskID: "task-1",
+ Worktrees: []*models.TaskSessionWorktree{
+ {ID: "assoc-1", WorktreeID: "wt-1", RepositoryID: "repo-a", WorktreePath: "/x/a"},
+ {ID: "assoc-2", WorktreeID: "wt-2", RepositoryID: "repo-b", WorktreePath: "/x/b"},
+ },
+ }
+
+ full := FromTaskSession(session)
+ if len(full.Worktrees) != 2 {
+ t.Fatalf("FromTaskSession Worktrees len = %d, want 2", len(full.Worktrees))
+ }
+ if full.WorktreePath != "/x/a" {
+ t.Fatalf("WorktreePath = %q, want /x/a (first worktree)", full.WorktreePath)
+ }
+
+ summary := FromTaskSessionSummary(session)
+ if len(summary.Worktrees) != 2 {
+ t.Fatalf("FromTaskSessionSummary Worktrees len = %d, want 2", len(summary.Worktrees))
+ }
+ if summary.Worktrees[1].WorktreeID != "wt-2" {
+ t.Fatalf("second worktree id = %q, want wt-2", summary.Worktrees[1].WorktreeID)
+ }
+}
diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go
index 47e3cc8315..805394ebe0 100644
--- a/apps/backend/internal/task/dto/dto.go
+++ b/apps/backend/internal/task/dto/dto.go
@@ -196,29 +196,33 @@ type TaskSessionDTO struct {
TaskID string `json:"task_id"`
// Name is the user-supplied session tab label. Serialized without
// omitempty so a cleared name ("") overwrites stale client state.
- Name string `json:"name"`
- AgentExecutionID string `json:"agent_execution_id,omitempty"`
- ContainerID string `json:"container_id,omitempty"`
- AgentProfileID string `json:"agent_profile_id,omitempty"`
- ExecutorID string `json:"executor_id,omitempty"`
- ExecutorProfileID string `json:"executor_profile_id,omitempty"`
- EnvironmentID string `json:"environment_id,omitempty"`
- RepositoryID string `json:"repository_id,omitempty"`
- BaseBranch string `json:"base_branch,omitempty"`
- BaseCommitSHA string `json:"base_commit_sha,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- WorktreePath string `json:"worktree_path,omitempty"`
- WorktreeBranch string `json:"worktree_branch,omitempty"`
- State models.TaskSessionState `json:"state"`
- ErrorMessage string `json:"error_message,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
- AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"`
- ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"`
- EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"`
- RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"`
- StartedAt time.Time `json:"started_at"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
- UpdatedAt time.Time `json:"updated_at"`
+ Name string `json:"name"`
+ AgentExecutionID string `json:"agent_execution_id,omitempty"`
+ ContainerID string `json:"container_id,omitempty"`
+ AgentProfileID string `json:"agent_profile_id,omitempty"`
+ ExecutorID string `json:"executor_id,omitempty"`
+ ExecutorProfileID string `json:"executor_profile_id,omitempty"`
+ EnvironmentID string `json:"environment_id,omitempty"`
+ RepositoryID string `json:"repository_id,omitempty"`
+ BaseBranch string `json:"base_branch,omitempty"`
+ BaseCommitSHA string `json:"base_commit_sha,omitempty"`
+ WorktreeID string `json:"worktree_id,omitempty"`
+ WorktreePath string `json:"worktree_path,omitempty"`
+ WorktreeBranch string `json:"worktree_branch,omitempty"`
+ // Worktrees lists all session worktrees (one per repo on multi-repo tasks);
+ // the flattened Worktree* fields above carry only the first for
+ // backward compatibility.
+ Worktrees []*models.TaskSessionWorktree `json:"worktrees,omitempty"`
+ State models.TaskSessionState `json:"state"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+ AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"`
+ ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"`
+ EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"`
+ RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"`
+ StartedAt time.Time `json:"started_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
// Workflow fields
IsPrimary bool `json:"is_primary"`
IsPassthrough bool `json:"is_passthrough"`
@@ -233,29 +237,33 @@ type TaskSessionSummaryDTO struct {
TaskID string `json:"task_id"`
// Name is the user-supplied session tab label. Serialized without
// omitempty so a cleared name ("") overwrites stale client state.
- Name string `json:"name"`
- AgentExecutionID string `json:"agent_execution_id,omitempty"`
- ContainerID string `json:"container_id,omitempty"`
- AgentProfileID string `json:"agent_profile_id,omitempty"`
- ExecutorID string `json:"executor_id,omitempty"`
- ExecutorProfileID string `json:"executor_profile_id,omitempty"`
- EnvironmentID string `json:"environment_id,omitempty"`
- RepositoryID string `json:"repository_id,omitempty"`
- BaseBranch string `json:"base_branch,omitempty"`
- BaseCommitSHA string `json:"base_commit_sha,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- WorktreePath string `json:"worktree_path,omitempty"`
- WorktreeBranch string `json:"worktree_branch,omitempty"`
- State models.TaskSessionState `json:"state"`
- ErrorMessage string `json:"error_message,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
- StartedAt time.Time `json:"started_at"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
- UpdatedAt time.Time `json:"updated_at"`
- IsPrimary bool `json:"is_primary"`
- IsPassthrough bool `json:"is_passthrough"`
- ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
- TaskEnvironmentID string `json:"task_environment_id,omitempty"`
+ Name string `json:"name"`
+ AgentExecutionID string `json:"agent_execution_id,omitempty"`
+ ContainerID string `json:"container_id,omitempty"`
+ AgentProfileID string `json:"agent_profile_id,omitempty"`
+ ExecutorID string `json:"executor_id,omitempty"`
+ ExecutorProfileID string `json:"executor_profile_id,omitempty"`
+ EnvironmentID string `json:"environment_id,omitempty"`
+ RepositoryID string `json:"repository_id,omitempty"`
+ BaseBranch string `json:"base_branch,omitempty"`
+ BaseCommitSHA string `json:"base_commit_sha,omitempty"`
+ WorktreeID string `json:"worktree_id,omitempty"`
+ WorktreePath string `json:"worktree_path,omitempty"`
+ WorktreeBranch string `json:"worktree_branch,omitempty"`
+ // Worktrees lists all session worktrees (one per repo on multi-repo tasks);
+ // the flattened Worktree* fields above carry only the first for
+ // backward compatibility.
+ Worktrees []*models.TaskSessionWorktree `json:"worktrees,omitempty"`
+ State models.TaskSessionState `json:"state"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+ StartedAt time.Time `json:"started_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+ IsPrimary bool `json:"is_primary"`
+ IsPassthrough bool `json:"is_passthrough"`
+ ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
+ TaskEnvironmentID string `json:"task_environment_id,omitempty"`
// CommandCount is the number of tool_call messages on this session,
// surfaced inline in the timeline entry header ("ran N commands").
// Populated by ListTaskSessions; defaults to 0 for callers that don't
@@ -662,6 +670,7 @@ func FromTaskSessionSummary(session *models.TaskSession) TaskSessionSummaryDTO {
result.WorktreeID = session.Worktrees[0].WorktreeID
result.WorktreePath = session.Worktrees[0].WorktreePath
result.WorktreeBranch = session.Worktrees[0].WorktreeBranch
+ result.Worktrees = session.Worktrees
}
return result
}
@@ -700,6 +709,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
result.WorktreeID = session.Worktrees[0].WorktreeID
result.WorktreePath = session.Worktrees[0].WorktreePath
result.WorktreeBranch = session.Worktrees[0].WorktreeBranch
+ result.Worktrees = session.Worktrees
}
return result
}
diff --git a/apps/web/components/task/editor-worktree-options.test.ts b/apps/web/components/task/editor-worktree-options.test.ts
new file mode 100644
index 0000000000..6cca74076a
--- /dev/null
+++ b/apps/web/components/task/editor-worktree-options.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { buildWorktreeOptions } from "./editor-worktree-options";
+import type { Worktree } from "@/lib/state/slices/session/types";
+import type { Repository } from "@/lib/types/http";
+
+function repo(id: string, name: string): Repository {
+ return { id, name } as Repository;
+}
+
+describe("buildWorktreeOptions", () => {
+ it("labels worktrees with their repository name", () => {
+ const worktrees: Worktree[] = [
+ { id: "wt-1", sessionId: "s-1", repositoryId: "repo-a", path: "/tasks/x/a", branch: "kd/x" },
+ { id: "wt-2", sessionId: "s-1", repositoryId: "repo-b", path: "/tasks/x/b", branch: "kd/y" },
+ ];
+ const options = buildWorktreeOptions(worktrees, [repo("repo-a", "api"), repo("repo-b", "web")]);
+ expect(options).toEqual([
+ { worktreeId: "wt-1", label: "api", branch: "kd/x" },
+ { worktreeId: "wt-2", label: "web", branch: "kd/y" },
+ ]);
+ });
+
+ it("falls back to the worktree folder name when the repository is unknown", () => {
+ const worktrees: Worktree[] = [
+ { id: "wt-1", sessionId: "s-1", repositoryId: "missing", path: "/tasks/x/kandev" },
+ ];
+ const options = buildWorktreeOptions(worktrees, []);
+ expect(options).toEqual([{ worktreeId: "wt-1", label: "kandev", branch: undefined }]);
+ });
+
+ it("handles Windows-style paths", () => {
+ const worktrees: Worktree[] = [{ id: "wt-1", sessionId: "s-1", path: "C:\\tasks\\x\\kandev" }];
+ expect(buildWorktreeOptions(worktrees, [])[0].label).toBe("kandev");
+ });
+
+ it("falls back to a positional label without repository or path", () => {
+ const worktrees: Worktree[] = [
+ { id: "wt-1", sessionId: "s-1" },
+ { id: "wt-2", sessionId: "s-1" },
+ ];
+ const options = buildWorktreeOptions(worktrees, []);
+ expect(options.map((o) => o.label)).toEqual(["Worktree 1", "Worktree 2"]);
+ });
+});
diff --git a/apps/web/components/task/editor-worktree-options.ts b/apps/web/components/task/editor-worktree-options.ts
new file mode 100644
index 0000000000..04ca065632
--- /dev/null
+++ b/apps/web/components/task/editor-worktree-options.ts
@@ -0,0 +1,29 @@
+import type { Worktree } from "@/lib/state/slices/session/types";
+import type { Repository } from "@/lib/types/http";
+
+export type WorktreeOption = {
+ worktreeId: string;
+ label: string;
+ branch?: string;
+};
+
+function worktreePathBasename(path: string | undefined): string | undefined {
+ if (!path) return undefined;
+ return path.split(/[\\/]/).filter(Boolean).pop();
+}
+
+/**
+ * Builds display options for a session's worktrees: labeled by repository name
+ * when known, otherwise by the worktree folder name.
+ */
+export function buildWorktreeOptions(
+ worktrees: Worktree[],
+ repositories: Repository[],
+): WorktreeOption[] {
+ return worktrees.map((worktree, index) => {
+ const repository = repositories.find((repo) => repo.id === worktree.repositoryId);
+ const label =
+ repository?.name ?? worktreePathBasename(worktree.path) ?? `Worktree ${index + 1}`;
+ return { worktreeId: worktree.id, label, branch: worktree.branch };
+ });
+}
diff --git a/apps/web/components/task/editors-menu.tsx b/apps/web/components/task/editors-menu.tsx
index 5989337378..80b3683d0e 100644
--- a/apps/web/components/task/editors-menu.tsx
+++ b/apps/web/components/task/editors-menu.tsx
@@ -7,22 +7,171 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useEditors } from "@/hooks/domains/settings/use-editors";
import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor";
+import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees";
import { useAppStore } from "@/components/state-provider";
import type { EditorOption } from "@/lib/types/http";
+import {
+ buildWorktreeOptions,
+ type WorktreeOption,
+} from "@/components/task/editor-worktree-options";
+
+const menuItemClass = "cursor-pointer";
type EditorsMenuProps = {
activeSessionId: string | null;
};
+function useWorktreeOptions(sessionId: string | null): WorktreeOption[] {
+ const worktrees = useSessionWorktrees(sessionId);
+ const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
+ return useMemo(
+ () => buildWorktreeOptions(worktrees, Object.values(repositoriesByWorkspace).flat()),
+ [worktrees, repositoriesByWorkspace],
+ );
+}
+
+function WorktreeItems({
+ options,
+ onSelect,
+}: {
+ options: WorktreeOption[];
+ onSelect: (worktreeId: string) => void;
+}) {
+ return (
+ <>
+ {options.map((option) => (
+