feat: choose worktree when opening IDE on multi-repo tasks (#1743)

* feat: choose worktree when opening IDE on multi-repo tasks

On multi-repo tasks the open-in-IDE button always opened the first
worktree. The editors menu now shows a worktree picker (repo + branch)
when a session has multiple worktrees, and each editor in the dropdown
expands into a per-worktree submenu.

- open-editor/open-folder accept an optional worktree_id; empty keeps
  the first-worktree fallback so existing clients are unaffected
- session DTOs now serialize the full worktrees list instead of only
  the flattened first-worktree fields
- useSessionWorktrees unions API worktrees with the WS-populated list
- new Playwright spec covers the picker on a real multi-repo session

* fix: address review feedback on worktree picker

- fall back on empty-string worktree fields, not only null/undefined
- bind optional open-folder body via EOF check instead of ContentLength
  so chunked requests keep their worktree_id
- wrap disabled editor buttons in a focusable span so tooltips work
- add hook test asserting worktreeId is forwarded as worktree_id

* fix: map editor service errors to HTTP status, drop e2e sleep

- open-editor/open-folder return 404/400/409 for known service errors
  (unknown worktree or editor, invalid config, unavailable editor)
  instead of a blanket 500
- replace the picker spec's 750ms sleep with an explicit composer click
  so the autofocus race is deterministic

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-07-16 22:43:06 +01:00
committed by GitHub
parent 685b17be26
commit ea84b913b4
18 changed files with 805 additions and 100 deletions
@@ -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
+9
View File
@@ -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"`
}
@@ -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)
@@ -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)
}
})
}
}
@@ -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
@@ -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)
}
@@ -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)
}
}
+56 -46
View File
@@ -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
}
@@ -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"]);
});
});
@@ -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 };
});
}
+167 -31
View File
@@ -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) => (
<DropdownMenuItem
key={option.worktreeId}
className={menuItemClass}
data-testid="editors-menu-worktree-item"
onClick={() => onSelect(option.worktreeId)}
>
<span className="truncate">{option.label}</span>
{option.branch && (
<span className="ml-auto pl-3 text-xs text-muted-foreground truncate max-w-40">
{option.branch}
</span>
)}
</DropdownMenuItem>
))}
</>
);
}
function OpenEditorButton({
disabled,
isLoading,
tooltip,
worktreeOptions,
onOpen,
}: {
disabled: boolean;
isLoading: boolean;
tooltip: string;
worktreeOptions: WorktreeOption[];
onOpen: (worktreeId?: string) => void;
}) {
const icon = isLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconCode className="h-4 w-4" />
);
const buttonClass = "rounded-none border-0 cursor-pointer px-2 focus-visible:ring-inset";
// Multi-worktree sessions get a picker instead of opening the first worktree.
// The focusable span keeps the tooltip reachable while the button is
// disabled (disabled buttons receive no pointer/focus events).
if (worktreeOptions.length > 1) {
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={disabled ? 0 : -1} className="inline-flex">
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className={buttonClass}
data-testid="editors-menu-open"
disabled={disabled}
>
{icon}
</Button>
</DropdownMenuTrigger>
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-56 w-auto">
<WorktreeItems options={worktreeOptions} onSelect={(worktreeId) => onOpen(worktreeId)} />
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={disabled ? 0 : -1} className="inline-flex">
<Button
size="sm"
variant="outline"
className={buttonClass}
data-testid="editors-menu-open"
onClick={() => onOpen()}
disabled={disabled}
>
{icon}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
function EditorMenuEntry({
editor,
worktreeOptions,
onOpen,
}: {
editor: EditorOption;
worktreeOptions: WorktreeOption[];
onOpen: (editorId: string, worktreeId?: string) => void;
}) {
if (worktreeOptions.length > 1) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={menuItemClass}>{editor.name}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-56 w-auto">
<WorktreeItems
options={worktreeOptions}
onSelect={(worktreeId) => onOpen(editor.id, worktreeId)}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem className={menuItemClass} onClick={() => onOpen(editor.id)}>
{editor.name}
</DropdownMenuItem>
);
}
export function EditorsMenu({ activeSessionId }: EditorsMenuProps) {
const openEditor = useOpenSessionInEditor(activeSessionId ?? null);
const { editors } = useEditors();
const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId);
const worktreeOptions = useWorktreeOptions(activeSessionId ?? null);
const enabledEditors = useMemo(
() =>
@@ -44,37 +193,27 @@ export function EditorsMenu({ activeSessionId }: EditorsMenuProps) {
return enabledEditors[0]?.id ?? "";
}, [defaultEditorId, enabledEditors]);
const openWith = (editorId: string, worktreeId?: string) => {
if (!editorId) return;
void openEditor.open({ editorId, worktreeId });
};
return (
<div className="inline-flex h-7 rounded-md border border-border overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 cursor-pointer px-2 focus-visible:ring-inset"
onClick={() => {
if (!resolvedEditorId) return;
void openEditor.open({ editorId: resolvedEditorId });
}}
disabled={!activeSessionId || openEditor.isLoading || enabledEditors.length === 0}
>
{openEditor.isLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconCode className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{activeSessionId ? "Open editor" : "Select a session to open its worktree"}
</TooltipContent>
</Tooltip>
<OpenEditorButton
disabled={!activeSessionId || openEditor.isLoading || enabledEditors.length === 0}
isLoading={openEditor.isLoading}
tooltip={activeSessionId ? "Open editor" : "Select a session to open its worktree"}
worktreeOptions={worktreeOptions}
onOpen={(worktreeId) => openWith(resolvedEditorId, worktreeId)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-none border-0 border-l px-2 cursor-pointer focus-visible:ring-inset"
data-testid="editors-menu-list"
disabled={!activeSessionId || enabledEditors.length === 0}
>
<IconChevronDown className="h-4 w-4" />
@@ -85,15 +224,12 @@ export function EditorsMenu({ activeSessionId }: EditorsMenuProps) {
<DropdownMenuItem disabled>No editors available</DropdownMenuItem>
) : (
enabledEditors.map((editor: EditorOption) => (
<DropdownMenuItem
<EditorMenuEntry
key={editor.id}
className="cursor-pointer"
onClick={() => {
void openEditor.open({ editorId: editor.id });
}}
>
{editor.name}
</DropdownMenuItem>
editor={editor}
worktreeOptions={worktreeOptions}
onOpen={openWith}
/>
))
)}
</DropdownMenuContent>
@@ -0,0 +1,74 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { makeGitEnv } from "../../helpers/git-helper";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT", "IDLE"];
/**
* Multi-repo tasks create one worktree per repository. The IDE button in the
* task top bar must offer a worktree picker instead of silently opening the
* first worktree (regression: it always opened Worktrees[0]).
*/
test.describe("Editors menu worktree picker", () => {
test("multi-repo task offers a worktree choice on the IDE button", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// Two disposable repos so the session materializes two worktrees.
const gitEnv = makeGitEnv(backend.tmpDir);
const repoIds: string[] = [];
for (const name of ["orders-api", "storefront-web"]) {
const repoDir = path.join(backend.tmpDir, "repos", name);
fs.mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
const repo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name,
});
repoIds.push(repo.id);
}
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Sync order totals across services",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: repoIds,
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 60_000, message: "Waiting for the agent session to finish" },
)
.toBe(true);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Park focus on the idle chat composer before opening the menu so a late
// composer autofocus can't steal focus and dismiss the Radix dropdown.
await session.idleInput().click();
// The IDE button now opens a worktree picker instead of launching directly.
await testPage.getByTestId("editors-menu-open").click();
const items = testPage.getByTestId("editors-menu-worktree-item");
await expect(items).toHaveCount(2);
await expect(items.filter({ hasText: "orders-api" })).toBeVisible();
await expect(items.filter({ hasText: "storefront-web" })).toBeVisible();
});
});
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { resolveSessionWorktrees } from "./use-session-worktrees";
import type { Worktree } from "@/lib/state/slices/session/types";
import type { RepositoryId, SessionId, TaskSession, TaskSessionWorktree } from "@/lib/types/http";
const SESSION_ID = "session-1" as SessionId;
function session(overrides: Partial<TaskSession>): TaskSession {
return { id: SESSION_ID, task_id: "task-1", state: "RUNNING", ...overrides } as TaskSession;
}
function apiWorktree(overrides: Partial<TaskSessionWorktree>): TaskSessionWorktree {
return {
id: "assoc-1",
session_id: SESSION_ID,
worktree_id: "wt-1",
position: 0,
...overrides,
};
}
describe("resolveSessionWorktrees", () => {
it("prefers the API worktree list, ordered by position", () => {
const result = resolveSessionWorktrees(
SESSION_ID,
session({
worktrees: [
apiWorktree({
id: "assoc-2",
worktree_id: "wt-2",
position: 1,
repository_id: "repo-b" as RepositoryId,
worktree_path: "/x/b",
worktree_branch: "kd/b",
}),
apiWorktree({
id: "assoc-1",
worktree_id: "wt-1",
position: 0,
repository_id: "repo-a" as RepositoryId,
worktree_path: "/x/a",
worktree_branch: "kd/a",
}),
],
}),
{},
undefined,
);
expect(result).toEqual([
{ id: "wt-1", sessionId: SESSION_ID, repositoryId: "repo-a", path: "/x/a", branch: "kd/a" },
{ id: "wt-2", sessionId: SESSION_ID, repositoryId: "repo-b", path: "/x/b", branch: "kd/b" },
]);
});
it("fills in missing API fields from the live worktrees map", () => {
const live: Record<string, Worktree> = {
"wt-1": { id: "wt-1", sessionId: SESSION_ID, repositoryId: "repo-a", path: "/x/a" },
};
const result = resolveSessionWorktrees(
SESSION_ID,
session({ worktrees: [apiWorktree({ worktree_id: "wt-1" })] }),
live,
undefined,
);
expect(result[0]).toMatchObject({ id: "wt-1", repositoryId: "repo-a", path: "/x/a" });
});
it("treats empty-string API fields as missing and falls back to live values", () => {
const live: Record<string, Worktree> = {
"wt-1": { id: "wt-1", sessionId: SESSION_ID, path: "/x/a", branch: "kd/a" },
};
const result = resolveSessionWorktrees(
SESSION_ID,
session({
worktrees: [apiWorktree({ worktree_id: "wt-1", worktree_path: "", worktree_branch: "" })],
}),
live,
undefined,
);
expect(result[0]).toMatchObject({ id: "wt-1", path: "/x/a", branch: "kd/a" });
});
it("unions WS-only worktrees with the API list", () => {
const live: Record<string, Worktree> = {
"wt-1": { id: "wt-1", sessionId: SESSION_ID, path: "/x/a" },
"wt-2": { id: "wt-2", sessionId: SESSION_ID, path: "/x/b" },
};
const result = resolveSessionWorktrees(
SESSION_ID,
session({ worktrees: [apiWorktree({ worktree_id: "wt-1", worktree_path: "/x/a" })] }),
live,
["wt-1", "wt-2"],
);
expect(result.map((wt) => wt.id)).toEqual(["wt-1", "wt-2"]);
});
it("falls back to the WS per-session list when the API list is empty", () => {
const live: Record<string, Worktree> = {
"wt-1": { id: "wt-1", sessionId: SESSION_ID, path: "/x/a" },
};
const result = resolveSessionWorktrees(SESSION_ID, session({}), live, ["wt-1", "wt-gone"]);
expect(result).toEqual([live["wt-1"]]);
});
it("falls back to the legacy single worktree_id field", () => {
const live: Record<string, Worktree> = {
"wt-1": { id: "wt-1", sessionId: SESSION_ID, path: "/x/a" },
};
const result = resolveSessionWorktrees(
SESSION_ID,
session({ worktree_id: "wt-1" }),
live,
undefined,
);
expect(result).toEqual([live["wt-1"]]);
});
it("returns empty when nothing is known", () => {
expect(resolveSessionWorktrees(SESSION_ID, session({}), {}, undefined)).toEqual([]);
});
});
@@ -1,6 +1,50 @@
import { useMemo } from "react";
import { useAppStore } from "@/components/state-provider";
import { useSession } from "@/hooks/domains/session/use-session";
import type { Worktree } from "@/lib/state/slices/session/types";
import type { TaskSession } from "@/lib/types/http";
/**
* Resolves the worktrees of a session by unioning the API-provided
* `session.worktrees` (complete, survives reloads) with the WS-populated
* per-session list (fresher after live add-branch events), filling in details
* from the WS worktrees map. Falls back to the legacy single `worktree_id`
* field when neither source has entries.
*/
export function resolveSessionWorktrees(
sessionId: string,
session: TaskSession | null,
worktrees: Record<string, Worktree>,
sessionWorktreeIds: string[] | undefined,
): Worktree[] {
const result: Worktree[] = [...(session?.worktrees ?? [])]
.sort((a, b) => a.position - b.position)
.map((wt) => {
const id = wt.worktree_id || wt.id;
const live = worktrees[id];
// `||` (not `??`): the Go backend may serialize these as empty strings,
// which should also fall back to the live WS-populated values.
return {
id,
sessionId,
repositoryId: wt.repository_id || live?.repositoryId,
path: wt.worktree_path || live?.path,
branch: wt.worktree_branch || live?.branch,
};
});
const seen = new Set(result.map((wt) => wt.id));
for (const id of sessionWorktreeIds ?? []) {
const live = worktrees[id];
if (!live || seen.has(id)) continue;
seen.add(id);
result.push(live);
}
if (result.length === 0 && session?.worktree_id) {
const worktree = worktrees[session.worktree_id];
return worktree ? [worktree] : [];
}
return result;
}
export function useSessionWorktrees(sessionId: string | null) {
const { session } = useSession(sessionId);
@@ -11,14 +55,11 @@ export function useSessionWorktrees(sessionId: string | null) {
return useMemo(() => {
if (!sessionId) return [];
const worktreeIds = sessionWorktreesBySessionId[sessionId];
if (worktreeIds?.length) {
return worktreeIds.map((id: string) => worktrees[id]).filter(Boolean);
}
if (session?.worktree_id) {
const worktree = worktrees[session.worktree_id];
return worktree ? [worktree] : [];
}
return [];
}, [session?.worktree_id, sessionId, sessionWorktreesBySessionId, worktrees]);
return resolveSessionWorktrees(
sessionId,
session,
worktrees,
sessionWorktreesBySessionId[sessionId],
);
}, [session, sessionId, sessionWorktreesBySessionId, worktrees]);
}
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
const mockOpenSessionInEditor = vi.fn();
const mockOpenFileInVscode = vi.fn();
const mockOpenInternalVscode = vi.fn();
vi.mock("@/lib/api", () => ({
openSessionInEditor: (...args: unknown[]) => mockOpenSessionInEditor(...args),
}));
vi.mock("@/lib/api/domains/vscode-api", () => ({
openFileInVscode: (...args: unknown[]) => mockOpenFileInVscode(...args),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: {
getState: () => ({ openInternalVscode: mockOpenInternalVscode }),
},
}));
vi.mock("@/lib/http/use-request", () => ({
useRequest: (fn: (options?: unknown) => Promise<unknown>) => ({
run: fn,
status: "idle",
isLoading: false,
}),
}));
import { useOpenSessionInEditor } from "./use-open-session-in-editor";
describe("useOpenSessionInEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
mockOpenSessionInEditor.mockResolvedValue({});
});
it("forwards the selected worktree as worktree_id", async () => {
const { result } = renderHook(() => useOpenSessionInEditor("session-1"));
await result.current.open({ editorId: "editor-1", worktreeId: "wt-2" });
expect(mockOpenSessionInEditor).toHaveBeenCalledWith(
"session-1",
expect.objectContaining({ editor_id: "editor-1", worktree_id: "wt-2" }),
expect.anything(),
);
});
it("omits worktree_id when no worktree is selected", async () => {
const { result } = renderHook(() => useOpenSessionInEditor("session-1"));
await result.current.open({ editorId: "editor-1" });
const payload = mockOpenSessionInEditor.mock.calls[0][1] as Record<string, unknown>;
expect(payload.worktree_id).toBeUndefined();
});
it("does not call the API without a session id", async () => {
const { result } = renderHook(() => useOpenSessionInEditor(null));
await result.current.open({ editorId: "editor-1", worktreeId: "wt-2" });
expect(mockOpenSessionInEditor).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ type OpenEditorOptions = {
column?: number;
editorId?: string;
editorType?: string;
worktreeId?: string;
};
/**
@@ -46,6 +47,7 @@ export function useOpenSessionInEditor(sessionId?: string | null) {
file_path: options?.filePath,
line: options?.line,
column: options?.column,
worktree_id: options?.worktreeId,
},
{ cache: "no-store" },
);
+1
View File
@@ -122,6 +122,7 @@ export async function openSessionInEditor(
file_path: string;
line: number;
column: number;
worktree_id: string;
}>,
options?: ApiRequestOptions,
) {
+15
View File
@@ -362,6 +362,20 @@ export type MoveTaskResponse = {
workflow_step: WorkflowStepDTO;
};
/** A worktree associated with a task session (one per repo on multi-repo tasks). */
export type TaskSessionWorktree = {
/** Session-worktree association ID. */
id: string;
session_id: SessionId;
worktree_id: string;
repository_id?: RepositoryId;
branch_slug?: string;
position: number;
worktree_path?: string;
worktree_branch?: string;
created_at?: string;
};
export type TaskSession = {
id: SessionId;
task_id: TaskId;
@@ -377,6 +391,7 @@ export type TaskSession = {
worktree_id?: string;
worktree_path?: string;
worktree_branch?: string;
worktrees?: TaskSessionWorktree[];
task_environment_id?: string;
state: TaskSessionState;
error_message?: string;