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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -122,6 +122,7 @@ export async function openSessionInEditor(
|
||||
file_path: string;
|
||||
line: number;
|
||||
column: number;
|
||||
worktree_id: string;
|
||||
}>,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user