feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while testing the feature end-to-end: - workspace_path stored on multi-repo TaskEnvironment was one level too high, so agentctl booted with the wrong WorkDir on cold start; fixed in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession. - Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo` through agentctl client → WS handler → frontend hook → SessionGit fan-out, so the right git repo is targeted (was running at the task root, which isn't a git repo for multi-repo tasks). - agentctl GitLog is multi-repo aware: when no `repo` is specified and per-repo trackers exist, fans out one log per repo and merges sorted newest-first with RepositoryName stamped on each commit. - Per-repo grouping for files (Changes panel) and commits, with collapsible group headers; commits section defaults to collapsed. Algorithm extracted into one generic groupByRepositoryName helper (was copy-pasted three times). - Task creation dialog rewritten around a unified `fs.repositories[]` list — no more "primary vs extras" split. Chip-style row with one pill per repo + branch, "+" to add, x to remove, GitHub URL toggle inline. Discovered (on-machine) repos appear in any chip's dropdown. Removes ~6 fields and 3 helpers from the dialog state. - Branch listing endpoints collapsed to one: GET /workspaces/:id/branches?repository_id=X | path=Y Frontend collapses to a single useBranches hook with a tagged-union source. Drops two HTTP routes, two service methods, two API client funcs, and one whole hook file. Tests added across the chain: workspace_path resolver, sort-commits- by-date, manager poll-mode propagation, RepoSubpaths, chip grouping + collapse + per-repo isLatest, group-by-repo helper, dialog payload builder + chip wiring + discovered-repo flow.
This commit is contained in:
@@ -391,7 +391,7 @@ func (a *lifecycleAdapter) GetGitLog(ctx context.Context, sessionID, baseCommit
|
||||
if agentClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return agentClient.GitLog(ctx, baseCommit, limit, targetBranch)
|
||||
return agentClient.GitLog(ctx, baseCommit, limit, targetBranch, "")
|
||||
}
|
||||
|
||||
// GetCumulativeDiff retrieves the cumulative diff for a session from baseCommit to HEAD.
|
||||
|
||||
@@ -153,6 +153,10 @@ type GitCommitRequest struct {
|
||||
Message string `json:"message"`
|
||||
StageAll bool `json:"stage_all"`
|
||||
Amend bool `json:"amend"`
|
||||
// Multi-repo: subpath of the repo to commit in (e.g. "kandev"). Empty for
|
||||
// single-repo workspaces. Required for multi-repo — committing at the task
|
||||
// root fails because it isn't itself a git repo.
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}
|
||||
|
||||
// GitRenameBranchRequest for worktree.rename_branch action
|
||||
@@ -164,19 +168,22 @@ type GitRenameBranchRequest struct {
|
||||
// GitStageRequest for worktree.stage action
|
||||
type GitStageRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Paths []string `json:"paths"` // Empty = stage all
|
||||
Paths []string `json:"paths"` // Empty = stage all
|
||||
Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo
|
||||
}
|
||||
|
||||
// GitUnstageRequest for worktree.unstage action
|
||||
type GitUnstageRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Paths []string `json:"paths"` // Empty = unstage all
|
||||
Paths []string `json:"paths"` // Empty = unstage all
|
||||
Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo
|
||||
}
|
||||
|
||||
// GitDiscardRequest for worktree.discard action
|
||||
type GitDiscardRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Paths []string `json:"paths"` // Required - files to discard
|
||||
Paths []string `json:"paths"` // Required - files to discard
|
||||
Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo
|
||||
}
|
||||
|
||||
// GitCreatePRRequest for worktree.create_pr action
|
||||
@@ -192,13 +199,15 @@ type GitCreatePRRequest struct {
|
||||
type GitRevertCommitRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo
|
||||
}
|
||||
|
||||
// GitResetRequest for worktree.reset action
|
||||
type GitResetRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
Mode string `json:"mode"` // "soft", "mixed", or "hard"
|
||||
Mode string `json:"mode"` // "soft", "mixed", or "hard"
|
||||
Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo
|
||||
}
|
||||
|
||||
// GitShowCommitRequest for session.commit_diff action
|
||||
@@ -359,7 +368,7 @@ func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Messag
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend)
|
||||
result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend, req.Repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commit failed: %w", err)
|
||||
}
|
||||
@@ -421,7 +430,7 @@ func (h *GitHandlers) wsReset(ctx context.Context, msg *ws.Message) (*ws.Message
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GitReset(ctx, req.CommitSHA, req.Mode)
|
||||
result, err := client.GitReset(ctx, req.CommitSHA, req.Mode, req.Repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reset failed: %w", err)
|
||||
}
|
||||
@@ -445,7 +454,7 @@ func (h *GitHandlers) wsStage(ctx context.Context, msg *ws.Message) (*ws.Message
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GitStage(ctx, req.Paths)
|
||||
result, err := client.GitStage(ctx, req.Paths, req.Repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stage failed: %w", err)
|
||||
}
|
||||
@@ -469,7 +478,7 @@ func (h *GitHandlers) wsUnstage(ctx context.Context, msg *ws.Message) (*ws.Messa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GitUnstage(ctx, req.Paths)
|
||||
result, err := client.GitUnstage(ctx, req.Paths, req.Repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unstage failed: %w", err)
|
||||
}
|
||||
@@ -497,7 +506,7 @@ func (h *GitHandlers) wsDiscard(ctx context.Context, msg *ws.Message) (*ws.Messa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GitDiscard(ctx, req.Paths)
|
||||
result, err := client.GitDiscard(ctx, req.Paths, req.Repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discard failed: %w", err)
|
||||
}
|
||||
@@ -567,7 +576,7 @@ func (h *GitHandlers) wsRevertCommit(ctx context.Context, msg *ws.Message) (*ws.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GitRevertCommit(ctx, req.CommitSHA)
|
||||
result, err := client.GitRevertCommit(ctx, req.CommitSHA, req.Repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("revert commit failed: %w", err)
|
||||
}
|
||||
@@ -702,7 +711,7 @@ func (h *GitHandlers) wsGitCommits(ctx context.Context, msg *ws.Message) (*ws.Me
|
||||
|
||||
// Use target branch for dynamic merge-base calculation.
|
||||
// This ensures accurate commit filtering even after rebases.
|
||||
result, err := agentClient.GitLog(ctx, baseCommit, req.Limit, targetBranch)
|
||||
result, err := agentClient.GitLog(ctx, baseCommit, req.Limit, targetBranch, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git log failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -243,6 +243,10 @@ type GitCommitData struct {
|
||||
Deletions int `json:"deletions"`
|
||||
CommittedAt string `json:"committed_at"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
// RepositoryName identifies which repo this commit belongs to in multi-repo
|
||||
// task workspaces. Empty for single-repo. Carried to the frontend so the
|
||||
// Commits panel can render per-repo group headers.
|
||||
RepositoryName string `json:"repository_name,omitempty"`
|
||||
}
|
||||
|
||||
type GitResetData struct {
|
||||
|
||||
@@ -261,15 +261,16 @@ func (p *EventPublisher) PublishGitCommit(execution *AgentExecution, commit *age
|
||||
AgentID: execution.ID,
|
||||
Timestamp: time.Now().Format(time.RFC3339Nano),
|
||||
Commit: &GitCommitData{
|
||||
CommitSHA: commit.CommitSHA,
|
||||
ParentSHA: commit.ParentSHA,
|
||||
Message: commit.Message,
|
||||
AuthorName: commit.AuthorName,
|
||||
AuthorEmail: commit.AuthorEmail,
|
||||
FilesChanged: commit.FilesChanged,
|
||||
Insertions: commit.Insertions,
|
||||
Deletions: commit.Deletions,
|
||||
CommittedAt: commit.CommittedAt.Format(time.RFC3339),
|
||||
CommitSHA: commit.CommitSHA,
|
||||
ParentSHA: commit.ParentSHA,
|
||||
Message: commit.Message,
|
||||
AuthorName: commit.AuthorName,
|
||||
AuthorEmail: commit.AuthorEmail,
|
||||
FilesChanged: commit.FilesChanged,
|
||||
Insertions: commit.Insertions,
|
||||
Deletions: commit.Deletions,
|
||||
CommittedAt: commit.CommittedAt.Format(time.RFC3339),
|
||||
RepositoryName: commit.RepositoryName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,11 +320,11 @@ func TestSpritesE2E_FullFlow(t *testing.T) {
|
||||
_, err = client.CreateFile(ctx, "e2e-change.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.GitStage(ctx, []string{"e2e-change.txt"})
|
||||
result, err := client.GitStage(ctx, []string{"e2e-change.txt"}, "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Success, "git stage should succeed: %s", result.Error)
|
||||
|
||||
result, err = client.GitCommit(ctx, "e2e test commit", false)
|
||||
result, err = client.GitCommit(ctx, "e2e test commit", false, false, "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Success, "git commit should succeed: %s", result.Error)
|
||||
t.Logf("git commit: %s", result.Output)
|
||||
|
||||
@@ -89,15 +89,18 @@ func (c *Client) GitAbort(ctx context.Context, operation string) (*GitOperationR
|
||||
// GitCommit creates a commit with the specified message.
|
||||
// If stageAll is true, all changes are staged before committing.
|
||||
// If amend is true, it amends the previous commit instead of creating a new one.
|
||||
func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool, amend bool) (*GitOperationResult, error) {
|
||||
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
|
||||
func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool, amend bool, repo string) (*GitOperationResult, error) {
|
||||
payload := struct {
|
||||
Message string `json:"message"`
|
||||
StageAll bool `json:"stage_all"`
|
||||
Amend bool `json:"amend"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}{
|
||||
Message: message,
|
||||
StageAll: stageAll,
|
||||
Amend: amend,
|
||||
Repo: repo,
|
||||
}
|
||||
return c.gitOperation(ctx, "/api/v1/git/commit", payload)
|
||||
}
|
||||
@@ -114,56 +117,71 @@ func (c *Client) GitRenameBranch(ctx context.Context, newName string) (*GitOpera
|
||||
|
||||
// GitStage stages files for commit.
|
||||
// If paths is empty, stages all changes (git add -A).
|
||||
func (c *Client) GitStage(ctx context.Context, paths []string) (*GitOperationResult, error) {
|
||||
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
|
||||
func (c *Client) GitStage(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) {
|
||||
payload := struct {
|
||||
Paths []string `json:"paths"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}{
|
||||
Paths: paths,
|
||||
Repo: repo,
|
||||
}
|
||||
return c.gitOperation(ctx, "/api/v1/git/stage", payload)
|
||||
}
|
||||
|
||||
// GitUnstage unstages files from the index.
|
||||
// If paths is empty, unstages all changes (git reset HEAD).
|
||||
func (c *Client) GitUnstage(ctx context.Context, paths []string) (*GitOperationResult, error) {
|
||||
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
|
||||
func (c *Client) GitUnstage(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) {
|
||||
payload := struct {
|
||||
Paths []string `json:"paths"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}{
|
||||
Paths: paths,
|
||||
Repo: repo,
|
||||
}
|
||||
return c.gitOperation(ctx, "/api/v1/git/unstage", payload)
|
||||
}
|
||||
|
||||
// GitDiscard discards changes to files, reverting them to HEAD.
|
||||
// Paths must not be empty - at least one file must be specified.
|
||||
func (c *Client) GitDiscard(ctx context.Context, paths []string) (*GitOperationResult, error) {
|
||||
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
|
||||
func (c *Client) GitDiscard(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) {
|
||||
payload := struct {
|
||||
Paths []string `json:"paths"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}{
|
||||
Paths: paths,
|
||||
Repo: repo,
|
||||
}
|
||||
return c.gitOperation(ctx, "/api/v1/git/discard", payload)
|
||||
}
|
||||
|
||||
// GitRevertCommit undoes the latest commit using git reset --soft, keeping changes staged.
|
||||
func (c *Client) GitRevertCommit(ctx context.Context, commitSHA string) (*GitOperationResult, error) {
|
||||
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
|
||||
func (c *Client) GitRevertCommit(ctx context.Context, commitSHA, repo string) (*GitOperationResult, error) {
|
||||
payload := struct {
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}{
|
||||
CommitSHA: commitSHA,
|
||||
Repo: repo,
|
||||
}
|
||||
return c.gitOperation(ctx, "/api/v1/git/revert-commit", payload)
|
||||
}
|
||||
|
||||
// GitReset resets HEAD to the specified commit.
|
||||
// Mode can be "soft" (keep changes staged), "mixed" (keep changes unstaged), or "hard" (discard all changes).
|
||||
func (c *Client) GitReset(ctx context.Context, commitSHA, mode string) (*GitOperationResult, error) {
|
||||
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
|
||||
func (c *Client) GitReset(ctx context.Context, commitSHA, mode, repo string) (*GitOperationResult, error) {
|
||||
payload := struct {
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
Mode string `json:"mode"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}{
|
||||
CommitSHA: commitSHA,
|
||||
Mode: mode,
|
||||
Repo: repo,
|
||||
}
|
||||
return c.gitOperation(ctx, "/api/v1/git/reset", payload)
|
||||
}
|
||||
@@ -322,17 +340,25 @@ type GitCommitInfo struct {
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Insertions int `json:"insertions"`
|
||||
Deletions int `json:"deletions"`
|
||||
// RepositoryName tags commits when fetched from a multi-repo subpath. Empty
|
||||
// for single-repo workspaces. Stamped client-side after the per-repo call
|
||||
// so callers can fan out across repos and merge.
|
||||
RepositoryName string `json:"repository_name,omitempty"`
|
||||
}
|
||||
|
||||
// GitLog gets the commit log from baseCommit to HEAD.
|
||||
// If baseCommit is empty, returns recent commits (limited by limit).
|
||||
// If targetBranch is provided, computes merge-base dynamically for accurate filtering.
|
||||
func (c *Client) GitLog(ctx context.Context, baseCommit string, limit int, targetBranch string) (*GitLogResult, error) {
|
||||
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
|
||||
func (c *Client) GitLog(ctx context.Context, baseCommit string, limit int, targetBranch, repo string) (*GitLogResult, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/v1/git/log?since=%s&limit=%d",
|
||||
c.baseURL, url.QueryEscape(baseCommit), limit)
|
||||
if targetBranch != "" {
|
||||
reqURL += "&target_branch=" + url.QueryEscape(targetBranch)
|
||||
}
|
||||
if repo != "" {
|
||||
reqURL += "&repo=" + url.QueryEscape(repo)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,12 +4,29 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// sortCommitsByCommittedAtDesc sorts commits newest-first using committed_at.
|
||||
// Commits with unparseable timestamps preserve their original relative order
|
||||
// (sort.SliceStable). Used when merging logs from multiple repos so the
|
||||
// combined timeline reads chronologically.
|
||||
func sortCommitsByCommittedAtDesc(commits []*process.GitCommitInfo) {
|
||||
sort.SliceStable(commits, func(i, j int) bool {
|
||||
ti, errI := time.Parse(time.RFC3339, commits[i].CommittedAt)
|
||||
tj, errJ := time.Parse(time.RFC3339, commits[j].CommittedAt)
|
||||
if errI != nil || errJ != nil {
|
||||
return false
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
}
|
||||
|
||||
// Repo selects a repository sub-directory of the workspace for multi-repo
|
||||
// task roots. Optional. Empty = workspace root (single-repo behavior).
|
||||
// All git request structs below embed this field via the standard `repo` JSON
|
||||
@@ -602,7 +619,13 @@ func (s *Server) handleGitReset(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleGitLog handles GET /api/v1/git/log
|
||||
// handleGitLog handles GET /api/v1/git/log.
|
||||
//
|
||||
// Multi-repo: when the caller doesn't pin a repo (?repo= empty) and the
|
||||
// workspace contains per-repo subdirs, fan out one log per repo and merge
|
||||
// the results, stamping RepositoryName on each commit so the frontend can
|
||||
// group them. Without this, the call would land on the workspace root which
|
||||
// for multi-repo task workspaces isn't a git repo and returns nothing.
|
||||
func (s *Server) handleGitLog(c *gin.Context) {
|
||||
var req GitLogRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
@@ -629,42 +652,90 @@ func (s *Server) handleGitLog(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
gitOp, gitOpErr := s.procMgr.GitOperatorFor(req.Repo)
|
||||
if gitOpErr != nil {
|
||||
c.JSON(http.StatusBadRequest, process.GitLogResult{
|
||||
Success: false,
|
||||
Error: gitOpErr.Error(),
|
||||
})
|
||||
return
|
||||
if req.Repo == "" {
|
||||
if subs := s.procMgr.RepoSubpaths(); len(subs) > 0 {
|
||||
s.handleGitLogMultiRepo(c, req, subs, limit)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := s.runGitLogForRepo(c, req, limit, req.Repo)
|
||||
if err != nil {
|
||||
s.handleGitError(c, "log", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// runGitLogForRepo runs git log against a single repo subpath. Returns a
|
||||
// result-with-error or a non-nil error for transport failures.
|
||||
func (s *Server) runGitLogForRepo(
|
||||
c *gin.Context,
|
||||
req GitLogRequest,
|
||||
limit int,
|
||||
repo string,
|
||||
) (*process.GitLogResult, error) {
|
||||
gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo)
|
||||
if gitOpErr != nil {
|
||||
return nil, gitOpErr
|
||||
}
|
||||
|
||||
// If target_branch is provided, compute merge-base dynamically.
|
||||
// This ensures we only show commits not yet merged into the target branch,
|
||||
// which is accurate even after rebases.
|
||||
baseCommit := req.Since
|
||||
if req.TargetBranch != "" {
|
||||
mergeBase, err := gitOp.GetMergeBase(c.Request.Context(), "HEAD", req.TargetBranch)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to compute merge-base, falling back to since",
|
||||
zap.String("target_branch", req.TargetBranch),
|
||||
zap.String("repo", repo),
|
||||
zap.Error(err))
|
||||
// Fall back to the provided base commit
|
||||
} else if mergeBase != "" {
|
||||
baseCommit = mergeBase
|
||||
}
|
||||
}
|
||||
|
||||
result, err := gitOp.GetLog(c.Request.Context(), baseCommit, limit)
|
||||
if err != nil {
|
||||
s.logger.Error("git log failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, process.GitLogResult{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
return gitOp.GetLog(c.Request.Context(), baseCommit, limit)
|
||||
}
|
||||
|
||||
// handleGitLogMultiRepo fans the request out across every per-repo tracker,
|
||||
// stamps RepositoryName on each returned commit, and merges into a single
|
||||
// log sorted by committed_at descending. Per-repo limits are applied
|
||||
// individually; the merged result is then truncated to the request limit.
|
||||
func (s *Server) handleGitLogMultiRepo(
|
||||
c *gin.Context,
|
||||
req GitLogRequest,
|
||||
subpaths []string,
|
||||
limit int,
|
||||
) {
|
||||
merged := make([]*process.GitCommitInfo, 0)
|
||||
for _, sub := range subpaths {
|
||||
result, err := s.runGitLogForRepo(c, req, limit, sub)
|
||||
if err != nil {
|
||||
s.logger.Warn("git log for repo failed; skipping",
|
||||
zap.String("repo", sub), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if !result.Success {
|
||||
s.logger.Warn("git log for repo returned failure; skipping",
|
||||
zap.String("repo", sub), zap.String("error", result.Error))
|
||||
continue
|
||||
}
|
||||
for _, commit := range result.Commits {
|
||||
commit.RepositoryName = sub
|
||||
merged = append(merged, commit)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
// Sort newest-first across repos. Falls back to insertion order if either
|
||||
// timestamp is unparseable.
|
||||
sortCommitsByCommittedAtDesc(merged)
|
||||
if len(merged) > limit {
|
||||
merged = merged[:limit]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, process.GitLogResult{
|
||||
Success: true,
|
||||
Commits: merged,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGitCumulativeDiff handles GET /api/v1/git/cumulative-diff
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
)
|
||||
|
||||
// sortCommitsByCommittedAtDesc must order commits newest-first so the merged
|
||||
// per-repo log reads chronologically, regardless of which repo bucket each
|
||||
// commit came from. Unparseable timestamps preserve their relative order via
|
||||
// SliceStable so we don't randomly reshuffle bad data.
|
||||
func TestSortCommitsByCommittedAtDesc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("interleaves commits from multiple repos by timestamp", func(t *testing.T) {
|
||||
commits := []*process.GitCommitInfo{
|
||||
{CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z", RepositoryName: "frontend"},
|
||||
{CommitSHA: "b", CommittedAt: "2026-04-26T12:00:00Z", RepositoryName: "backend"},
|
||||
{CommitSHA: "c", CommittedAt: "2026-04-26T11:00:00Z", RepositoryName: "frontend"},
|
||||
}
|
||||
sortCommitsByCommittedAtDesc(commits)
|
||||
got := []string{commits[0].CommitSHA, commits[1].CommitSHA, commits[2].CommitSHA}
|
||||
want := []string{"b", "c", "a"}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("position %d: got %q, want %q (full order: %v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves order on unparseable timestamps", func(t *testing.T) {
|
||||
commits := []*process.GitCommitInfo{
|
||||
{CommitSHA: "x", CommittedAt: "not-a-date"},
|
||||
{CommitSHA: "y", CommittedAt: "also-bad"},
|
||||
}
|
||||
sortCommitsByCommittedAtDesc(commits)
|
||||
if commits[0].CommitSHA != "x" || commits[1].CommitSHA != "y" {
|
||||
t.Errorf("expected stable order on bad timestamps; got %s, %s",
|
||||
commits[0].CommitSHA, commits[1].CommitSHA)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,19 @@ type SetPollModeRequest struct {
|
||||
// See plan: focus-gated git polling. The gateway computes a workspace-level
|
||||
// mode (fast if any session focused, slow if any subscribed, paused otherwise)
|
||||
// and pushes it here so the tracker can throttle expensive git scans.
|
||||
//
|
||||
// Multi-repo: the per-repo trackers also need the mode update — otherwise they
|
||||
// keep their construction-time default (fast) and never throttle, AND they
|
||||
// miss the focus-triggered immediate scan that handleMonitorModeChange does
|
||||
// on a transition INTO fast. Fanning out keeps every repo in lockstep.
|
||||
//
|
||||
// Snapshot-on-focus: a transition to fast/slow forces a RefreshGitStatus on
|
||||
// every tracker so a fresh git status reaches subscribers. monitorTick
|
||||
// otherwise only pushes on detected change; without this, a page opened
|
||||
// after the agent has been idle would see no events until the user makes
|
||||
// a file change. Multi-repo amplifies it: per-repo state is a Map and the
|
||||
// aggregator only renders the repos that have published — missing events
|
||||
// = missing files in the Changes panel, not just stale data.
|
||||
func (s *Server) handleSetPollMode(c *gin.Context) {
|
||||
var req SetPollModeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -32,7 +45,7 @@ func (s *Server) handleSetPollMode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
s.procMgr.GetWorkspaceTracker().SetPollMode(mode)
|
||||
s.procMgr.SetWorkspacePollMode(c.Request.Context(), mode)
|
||||
s.logger.Debug("workspace poll mode updated", zap.String("mode", req.Mode))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"mode": req.Mode})
|
||||
|
||||
@@ -39,6 +39,10 @@ type GitOperator struct {
|
||||
workDir string
|
||||
logger *logger.Logger
|
||||
workspaceTracker *WorkspaceTracker
|
||||
// repoName is the multi-repo subpath this operator runs in (e.g. "kandev").
|
||||
// Empty for the workspace-root operator. Stamped on emitted commit
|
||||
// notifications so the frontend can group commits per repo.
|
||||
repoName string
|
||||
|
||||
mu sync.Mutex // Prevents concurrent git operations
|
||||
inProgress bool
|
||||
@@ -54,6 +58,14 @@ func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *Worksp
|
||||
}
|
||||
}
|
||||
|
||||
// NewGitOperatorForRepo creates a GitOperator scoped to a multi-repo subpath
|
||||
// so its emitted events (e.g. commit notifications) carry the repo name.
|
||||
func NewGitOperatorForRepo(workDir, repoName string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator {
|
||||
op := NewGitOperator(workDir, log, workspaceTracker)
|
||||
op.repoName = repoName
|
||||
return op
|
||||
}
|
||||
|
||||
// runGitCommand executes a git command in the workDir with defense-in-depth validation.
|
||||
// Validates both flags and branch/ref arguments to prevent command injection.
|
||||
func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) {
|
||||
@@ -503,15 +515,16 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool,
|
||||
filesChanged, insertions, deletions := g.getCommitStats(ctx, strings.TrimSpace(commitSHA))
|
||||
|
||||
commit := &streams.GitCommitNotification{
|
||||
CommitSHA: strings.TrimSpace(commitSHA),
|
||||
ParentSHA: strings.TrimSpace(parentSHA),
|
||||
Message: message,
|
||||
AuthorName: authorName,
|
||||
AuthorEmail: authorEmail,
|
||||
FilesChanged: filesChanged,
|
||||
Insertions: insertions,
|
||||
Deletions: deletions,
|
||||
CommittedAt: time.Now().UTC(),
|
||||
RepositoryName: g.repoName,
|
||||
CommitSHA: strings.TrimSpace(commitSHA),
|
||||
ParentSHA: strings.TrimSpace(parentSHA),
|
||||
Message: message,
|
||||
AuthorName: authorName,
|
||||
AuthorEmail: authorEmail,
|
||||
FilesChanged: filesChanged,
|
||||
Insertions: insertions,
|
||||
Deletions: deletions,
|
||||
CommittedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
g.workspaceTracker.NotifyGitCommit(commit)
|
||||
|
||||
@@ -25,6 +25,9 @@ type GitCommitInfo struct {
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Insertions int `json:"insertions"`
|
||||
Deletions int `json:"deletions"`
|
||||
// RepositoryName tags commits returned from a multi-repo log fan-out.
|
||||
// Empty for single-repo workspaces. Set by the API layer after the call.
|
||||
RepositoryName string `json:"repository_name,omitempty"`
|
||||
}
|
||||
|
||||
// CumulativeDiffResult represents the cumulative diff from base commit to HEAD.
|
||||
|
||||
@@ -321,6 +321,44 @@ func (m *Manager) ListProcesses(sessionID string) []ProcessInfo {
|
||||
return m.processRunner.List(sessionID)
|
||||
}
|
||||
|
||||
// RepoSubpaths returns the subpath name (relative to cfg.WorkDir) for every
|
||||
// per-repo tracker discovered at construction time. Empty for single-repo
|
||||
// workspaces. Used by callers that want to fan an op out across repos.
|
||||
func (m *Manager) RepoSubpaths() []string {
|
||||
out := make([]string, 0, len(m.repoTrackers))
|
||||
for _, t := range m.repoTrackers {
|
||||
if t.repositoryName != "" {
|
||||
out = append(out, t.repositoryName)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetWorkspacePollMode propagates a poll-mode change to the root tracker and
|
||||
// every per-repo tracker, then forces a RefreshGitStatus on each non-paused
|
||||
// tracker so a fresh snapshot reaches every subscriber. Without the refresh,
|
||||
// monitorTick only pushes on detected change — multi-repo workspaces would
|
||||
// leave the per-repo state map sparse (one repo present, the other missing)
|
||||
// after a focus event, since the agent's initial pushes happen at boot and
|
||||
// no replay path exists for clients that subscribe later.
|
||||
func (m *Manager) SetWorkspacePollMode(ctx context.Context, mode PollMode) {
|
||||
m.workspaceTracker.SetPollMode(mode)
|
||||
for _, t := range m.repoTrackers {
|
||||
t.SetPollMode(mode)
|
||||
}
|
||||
if mode == PollModePaused {
|
||||
return
|
||||
}
|
||||
// Refresh in background — RefreshGitStatus blocks on git commands which can
|
||||
// take seconds on large repos; the HTTP caller shouldn't wait.
|
||||
go func() {
|
||||
m.workspaceTracker.RefreshGitStatus(ctx)
|
||||
for _, t := range m.repoTrackers {
|
||||
t.RefreshGitStatus(ctx)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GitOperator returns the git operator for git operations against the
|
||||
// workspace root. Lazy-initialized.
|
||||
func (m *Manager) GitOperator() *GitOperator {
|
||||
@@ -357,7 +395,7 @@ func (m *Manager) GitOperatorFor(subpath string) (*GitOperator, error) {
|
||||
if op, ok := m.gitOperatorsBySubpath[cleaned]; ok {
|
||||
return op, nil
|
||||
}
|
||||
op := NewGitOperator(full, m.logger, m.workspaceTracker)
|
||||
op := NewGitOperatorForRepo(full, cleaned, m.logger, m.workspaceTracker)
|
||||
m.gitOperatorsBySubpath[cleaned] = op
|
||||
return op, nil
|
||||
}
|
||||
|
||||
@@ -82,3 +82,59 @@ func TestManager_SingleRepo_NoPerRepoTrackers(t *testing.T) {
|
||||
t.Errorf("single-repo workspace should not have per-repo trackers; got %d", len(mgr.repoTrackers))
|
||||
}
|
||||
}
|
||||
|
||||
// RepoSubpaths must list every per-repo tracker's directory name (and
|
||||
// nothing else) so the API layer can fan git-log out across repos.
|
||||
func TestManager_RepoSubpaths(t *testing.T) {
|
||||
taskRoot := t.TempDir()
|
||||
for _, name := range []string{"frontend", "backend"} {
|
||||
repoDir, cleanup := setupTestRepo(t)
|
||||
t.Cleanup(cleanup)
|
||||
if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil {
|
||||
t.Fatalf("place %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t))
|
||||
subs := mgr.RepoSubpaths()
|
||||
want := map[string]bool{"frontend": true, "backend": true}
|
||||
if len(subs) != len(want) {
|
||||
t.Fatalf("RepoSubpaths length = %d, want %d (got %v)", len(subs), len(want), subs)
|
||||
}
|
||||
for _, s := range subs {
|
||||
if !want[s] {
|
||||
t.Errorf("unexpected repo subpath %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that SetWorkspacePollMode propagates to every per-repo tracker.
|
||||
// Without the fan-out, per-repo trackers stay at their construction-time
|
||||
// default and never receive the focus-triggered refresh that delivers a
|
||||
// fresh git status snapshot to a subscriber attached after agent boot.
|
||||
func TestManager_SetWorkspacePollMode_PropagatesToPerRepoTrackers(t *testing.T) {
|
||||
taskRoot := t.TempDir()
|
||||
for _, name := range []string{"frontend", "backend"} {
|
||||
repoDir, cleanup := setupTestRepo(t)
|
||||
t.Cleanup(cleanup)
|
||||
if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil {
|
||||
t.Fatalf("place %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t))
|
||||
if len(mgr.repoTrackers) != 2 {
|
||||
t.Fatalf("expected 2 per-repo trackers, got %d", len(mgr.repoTrackers))
|
||||
}
|
||||
|
||||
mgr.SetWorkspacePollMode(context.Background(), PollModeSlow)
|
||||
|
||||
if got := mgr.workspaceTracker.GetPollMode(); got != PollModeSlow {
|
||||
t.Errorf("root tracker mode = %v, want %v", got, PollModeSlow)
|
||||
}
|
||||
for i, tr := range mgr.repoTrackers {
|
||||
if got := tr.GetPollMode(); got != PollModeSlow {
|
||||
t.Errorf("repoTrackers[%d] mode = %v, want %v", i, got, PollModeSlow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,16 +71,17 @@ func (wt *WorkspaceTracker) getCommitsSince(ctx context.Context, baseCommit stri
|
||||
}
|
||||
|
||||
commits = append(commits, &types.GitCommitNotification{
|
||||
Timestamp: time.Now(),
|
||||
CommitSHA: sha,
|
||||
ParentSHA: parentSHA,
|
||||
AuthorName: parts[2],
|
||||
AuthorEmail: parts[3],
|
||||
Message: parts[4],
|
||||
FilesChanged: filesChanged,
|
||||
Insertions: insertions,
|
||||
Deletions: deletions,
|
||||
CommittedAt: committedAt,
|
||||
Timestamp: time.Now(),
|
||||
RepositoryName: wt.repositoryName,
|
||||
CommitSHA: sha,
|
||||
ParentSHA: parentSHA,
|
||||
AuthorName: parts[2],
|
||||
AuthorEmail: parts[3],
|
||||
Message: parts[4],
|
||||
FilesChanged: filesChanged,
|
||||
Insertions: insertions,
|
||||
Deletions: deletions,
|
||||
CommittedAt: committedAt,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,11 @@ type GitCommitNotification struct {
|
||||
// Timestamp is when this notification was created.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// RepositoryName identifies which repository this commit belongs to in
|
||||
// multi-repo task workspaces. Empty for single-repo. Carried through to
|
||||
// the frontend so the Commits panel can render per-repo group headers.
|
||||
RepositoryName string `json:"repository_name,omitempty"`
|
||||
|
||||
// CommitSHA is the SHA of the new commit.
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
|
||||
|
||||
@@ -878,14 +878,7 @@ func (e *Executor) persistTaskEnvironment(
|
||||
}
|
||||
|
||||
// Create a new task environment
|
||||
workspacePath := resp.WorktreePath
|
||||
if workspacePath == "" {
|
||||
workspacePath = req.RepositoryPath
|
||||
}
|
||||
// Task directory mode: WorkspacePath = task root, WorktreePath = repo subdir
|
||||
if req.TaskDirName != "" && resp.WorktreePath != "" {
|
||||
workspacePath = filepath.Dir(resp.WorktreePath)
|
||||
}
|
||||
workspacePath := resolveTaskEnvWorkspacePath(req, resp)
|
||||
env := &models.TaskEnvironment{
|
||||
TaskID: taskID,
|
||||
RepositoryID: req.RepositoryID,
|
||||
@@ -931,6 +924,30 @@ func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnv
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveTaskEnvWorkspacePath returns the host path that should be persisted on
|
||||
// the TaskEnvironment row (and used as agentctl's WorkDir on recreation).
|
||||
//
|
||||
// In task-directory mode the desired value is always the task root that holds
|
||||
// every per-repo worktree as a sibling. The legacy single-repo path passes
|
||||
// resp.WorktreePath as that repo's subdir, so the parent is the task root.
|
||||
// The multi-repo path on the lifecycle adapter mirrors agentctl's WorkDir
|
||||
// (already the task root) into resp.WorktreePath, so it must be used as-is —
|
||||
// applying filepath.Dir would walk up one level too far and point at the
|
||||
// /tasks holder instead.
|
||||
func resolveTaskEnvWorkspacePath(req *LaunchAgentRequest, resp *LaunchAgentResponse) string {
|
||||
workspacePath := resp.WorktreePath
|
||||
if workspacePath == "" {
|
||||
workspacePath = req.RepositoryPath
|
||||
}
|
||||
if req.TaskDirName == "" || resp.WorktreePath == "" {
|
||||
return workspacePath
|
||||
}
|
||||
if len(resp.Worktrees) > 1 {
|
||||
return resp.WorktreePath
|
||||
}
|
||||
return filepath.Dir(resp.WorktreePath)
|
||||
}
|
||||
|
||||
// persistTaskEnvironmentRepos inserts per-repo rows under an existing env id,
|
||||
// skipping any (env, repo) pair that already exists. Used when an existing
|
||||
// environment is reused (resume / re-launch on the same task).
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package executor
|
||||
|
||||
import "testing"
|
||||
|
||||
// Pinpointed tests for resolveTaskEnvWorkspacePath. The persisted workspace_path
|
||||
// becomes agentctl's WorkDir on cold start (GetOrEnsureExecution); pointing it
|
||||
// at the wrong directory disables the per-repo tracker fan-out and silently
|
||||
// drops the Changes panel back to single-repo mode.
|
||||
func TestResolveTaskEnvWorkspacePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("multi-repo keeps task root", func(t *testing.T) {
|
||||
req := &LaunchAgentRequest{TaskDirName: "do-nothing_mvo"}
|
||||
resp := &LaunchAgentResponse{
|
||||
// Lifecycle adapter mirrors agentctl WorkDir (task root) into
|
||||
// the legacy WorktreePath field for multi-repo launches.
|
||||
WorktreePath: "/tmp/tasks/do-nothing_mvo",
|
||||
Worktrees: []RepoWorktreeResult{
|
||||
{WorktreePath: "/tmp/tasks/do-nothing_mvo/kandev"},
|
||||
{WorktreePath: "/tmp/tasks/do-nothing_mvo/thm"},
|
||||
},
|
||||
}
|
||||
if got := resolveTaskEnvWorkspacePath(req, resp); got != "/tmp/tasks/do-nothing_mvo" {
|
||||
t.Fatalf("multi-repo: want /tmp/tasks/do-nothing_mvo, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single-repo derives task root from repo subdir", func(t *testing.T) {
|
||||
req := &LaunchAgentRequest{TaskDirName: "fix-thing_abc"}
|
||||
resp := &LaunchAgentResponse{
|
||||
WorktreePath: "/tmp/tasks/fix-thing_abc/kandev",
|
||||
}
|
||||
if got := resolveTaskEnvWorkspacePath(req, resp); got != "/tmp/tasks/fix-thing_abc" {
|
||||
t.Fatalf("single-repo: want /tmp/tasks/fix-thing_abc, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-task-dir mode passes worktree path through", func(t *testing.T) {
|
||||
req := &LaunchAgentRequest{} // TaskDirName empty
|
||||
resp := &LaunchAgentResponse{WorktreePath: "/legacy/worktrees/xyz"}
|
||||
if got := resolveTaskEnvWorkspacePath(req, resp); got != "/legacy/worktrees/xyz" {
|
||||
t.Fatalf("non-task-dir: want /legacy/worktrees/xyz, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no worktree falls back to repository path", func(t *testing.T) {
|
||||
req := &LaunchAgentRequest{RepositoryPath: "/repos/myrepo"}
|
||||
resp := &LaunchAgentResponse{} // no WorktreePath
|
||||
if got := resolveTaskEnvWorkspacePath(req, resp); got != "/repos/myrepo" {
|
||||
t.Fatalf("fallback: want /repos/myrepo, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -38,10 +38,13 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.GET("/workspaces/:id/repositories", h.httpListRepositories)
|
||||
api.POST("/workspaces/:id/repositories", h.httpCreateRepository)
|
||||
api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories)
|
||||
api.GET("/workspaces/:id/repositories/branches", h.httpListLocalRepositoryBranches)
|
||||
// Unified branch listing — accepts either ?repository_id= for an imported
|
||||
// workspace repo, or ?path= for an on-machine folder discovered but not
|
||||
// yet imported. Both paths bottom out in `listGitBranches`; the only
|
||||
// difference is how the absolute path is resolved.
|
||||
api.GET("/workspaces/:id/branches", h.httpListBranches)
|
||||
api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath)
|
||||
api.GET("/repositories/:id", h.httpGetRepository)
|
||||
api.GET("/repositories/:id/branches", h.httpListRepositoryBranches)
|
||||
api.PATCH("/repositories/:id", h.httpUpdateRepository)
|
||||
api.DELETE("/repositories/:id", h.httpDeleteRepository)
|
||||
api.GET("/repositories/:id/scripts", h.httpListRepositoryScripts)
|
||||
@@ -150,20 +153,33 @@ func (h *RepositoryHandlers) httpValidateRepositoryPath(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpListLocalRepositoryBranches(c *gin.Context) {
|
||||
// httpListBranches handles the unified branch endpoint:
|
||||
//
|
||||
// GET /api/v1/workspaces/:id/branches?repository_id=X
|
||||
// GET /api/v1/workspaces/:id/branches?path=/abs/path
|
||||
//
|
||||
// Exactly one of the two query params must be set. Both bottom out in the
|
||||
// same `listGitBranches` call; the difference is just where the absolute
|
||||
// path is resolved from (DB row vs request param).
|
||||
func (h *RepositoryHandlers) httpListBranches(c *gin.Context) {
|
||||
repoID := c.Query("repository_id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"})
|
||||
if repoID == "" && path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or path is required"})
|
||||
return
|
||||
}
|
||||
branches, err := h.service.ListLocalRepositoryBranches(c.Request.Context(), path)
|
||||
if repoID != "" && path != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "specify only one of repository_id or path"})
|
||||
return
|
||||
}
|
||||
branches, err := h.service.ListBranches(c.Request.Context(), repoID, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrPathNotAllowed) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path is not within allowed roots"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to list local repository branches", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list local repository branches"})
|
||||
h.logger.Error("failed to list branches", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list branches"})
|
||||
return
|
||||
}
|
||||
dtoBranches := make([]dto.BranchDTO, len(branches))
|
||||
@@ -239,26 +255,6 @@ func (h *RepositoryHandlers) httpGetRepository(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, dto.FromRepository(repository))
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpListRepositoryBranches(c *gin.Context) {
|
||||
branches, err := h.service.ListRepositoryBranches(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrPathNotAllowed) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "repository path is not allowed"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to list repository branches", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list repository branches"})
|
||||
return
|
||||
}
|
||||
dtoBranches := make([]dto.BranchDTO, len(branches))
|
||||
for i, branch := range branches {
|
||||
dtoBranches[i] = dto.FromBranch(branch)
|
||||
}
|
||||
c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{
|
||||
Branches: dtoBranches,
|
||||
Total: len(dtoBranches),
|
||||
})
|
||||
}
|
||||
|
||||
type httpUpdateRepositoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
|
||||
@@ -127,50 +127,50 @@ func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([]Branch, error) {
|
||||
repo, err := s.repoEntities.GetRepository(ctx, repoID)
|
||||
// ListBranches lists git branches for either an imported workspace repo
|
||||
// (by id, path resolved from the DB row) or an on-machine folder (by path
|
||||
// directly). Exactly one of `repoID` or `path` should be set; the handler
|
||||
// validates that.
|
||||
func (s *Service) ListBranches(ctx context.Context, repoID, path string) ([]Branch, error) {
|
||||
resolvedPath, err := s.resolveBranchListingPath(ctx, repoID, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if repo.LocalPath == "" {
|
||||
return nil, fmt.Errorf("repository local path is empty")
|
||||
}
|
||||
absPath, err := filepath.Abs(repo.LocalPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid repository path: %w", err)
|
||||
}
|
||||
if !isPathAllowed(absPath, s.discoveryRoots()) {
|
||||
return nil, ErrPathNotAllowed
|
||||
}
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("repository path is not a directory")
|
||||
}
|
||||
return listGitBranches(absPath)
|
||||
return listGitBranches(resolvedPath)
|
||||
}
|
||||
|
||||
func (s *Service) ListLocalRepositoryBranches(ctx context.Context, path string) ([]Branch, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("repository path is required")
|
||||
// resolveBranchListingPath turns the request inputs into a validated absolute
|
||||
// path to list branches in. Validation is identical for both inputs (must be
|
||||
// allowed root, must exist, must be a directory) — only the source differs.
|
||||
func (s *Service) resolveBranchListingPath(ctx context.Context, repoID, path string) (string, error) {
|
||||
switch {
|
||||
case repoID != "":
|
||||
repo, err := s.repoEntities.GetRepository(ctx, repoID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if repo.LocalPath == "" {
|
||||
return "", fmt.Errorf("repository local path is empty")
|
||||
}
|
||||
path = repo.LocalPath
|
||||
case path == "":
|
||||
return "", fmt.Errorf("repository_id or path is required")
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid repository path: %w", err)
|
||||
return "", fmt.Errorf("invalid repository path: %w", err)
|
||||
}
|
||||
if !isPathAllowed(absPath, s.discoveryRoots()) {
|
||||
return nil, ErrPathNotAllowed
|
||||
return "", ErrPathNotAllowed
|
||||
}
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("repository path is not a directory")
|
||||
return "", fmt.Errorf("repository path is not a directory")
|
||||
}
|
||||
return listGitBranches(absPath)
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (s *Service) discoveryRoots() []string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -202,9 +203,16 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio
|
||||
taskID = session.TaskID
|
||||
}
|
||||
|
||||
// Get workspace path from the session's worktree
|
||||
// Get workspace path from the session's worktree(s).
|
||||
// Multi-repo: every per-repo worktree sits as a sibling under the task root
|
||||
// (~/.kandev/tasks/{taskDirName}/{repoName}/), so the workspace path agentctl
|
||||
// needs is the parent of any one of them. Picking the first repo's path here
|
||||
// would point agentctl at a single repo subdir and disable the per-repo
|
||||
// tracker fan-out that scanRepositorySubdirs relies on.
|
||||
var workspacePath string
|
||||
if len(session.Worktrees) > 0 {
|
||||
if len(session.Worktrees) > 1 {
|
||||
workspacePath = filepath.Dir(session.Worktrees[0].WorktreePath)
|
||||
} else if len(session.Worktrees) == 1 {
|
||||
workspacePath = session.Worktrees[0].WorktreePath
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -208,3 +209,52 @@ func TestGetWorkspaceInfoForSession_SessionNotFound(t *testing.T) {
|
||||
t.Fatal("expected error for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-repo: the workspace path agentctl boots with must be the task root
|
||||
// (parent of every per-repo subdir) so its scanRepositorySubdirs detects all
|
||||
// repos and starts a per-repo tracker for each. Returning a single repo's
|
||||
// path would collapse fan-out into the legacy single-tracker mode and
|
||||
// suppress the per-repo events the Changes panel needs to render headers.
|
||||
func TestGetWorkspaceInfoForSession_MultiRepoReturnsTaskRoot(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
setupTestTask(t, repo)
|
||||
now := time.Now().UTC()
|
||||
|
||||
session := &models.TaskSession{
|
||||
ID: "session-multi",
|
||||
TaskID: "task-123",
|
||||
State: models.TaskSessionStateCompleted,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := repo.CreateTaskSession(ctx, session); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
for i, path := range []string{
|
||||
"/tmp/tasks/do-nothing_mvo/kandev",
|
||||
"/tmp/tasks/do-nothing_mvo/thm",
|
||||
} {
|
||||
if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{
|
||||
ID: fmt.Sprintf("wt%d", i),
|
||||
SessionID: session.ID,
|
||||
WorktreeID: fmt.Sprintf("wid%d", i),
|
||||
RepositoryID: fmt.Sprintf("repo%d", i),
|
||||
Position: i,
|
||||
WorktreePath: path,
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create worktree %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceInfoForSession: %v", err)
|
||||
}
|
||||
if info.WorkspacePath != "/tmp/tasks/do-nothing_mvo" {
|
||||
t.Errorf("expected WorkspacePath '/tmp/tasks/do-nothing_mvo' (task root), got %q",
|
||||
info.WorkspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,16 +164,6 @@ export async function listRepositoriesAction(
|
||||
return fetchJson<ListRepositoriesResponse>(url);
|
||||
}
|
||||
|
||||
export async function listLocalRepositoryBranchesAction(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
): Promise<RepositoryBranchesResponse> {
|
||||
const params = `?path=${encodeURIComponent(path)}`;
|
||||
return fetchJson<RepositoryBranchesResponse>(
|
||||
`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/branches${params}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function discoverRepositoriesAction(
|
||||
workspaceId: string,
|
||||
root?: string,
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ReviewFile } from "./types";
|
||||
|
||||
/**
|
||||
* Splits files into per-repository groups, preserving file order within each
|
||||
* group. Files with no repository_name fall into a single trailing group with
|
||||
* empty name (rendered without a repo header).
|
||||
*/
|
||||
export function groupFilesByRepository(
|
||||
files: ReviewFile[],
|
||||
): Array<{ repositoryName: string; files: ReviewFile[] }> {
|
||||
const order: string[] = [];
|
||||
const buckets = new Map<string, ReviewFile[]>();
|
||||
for (const file of files) {
|
||||
const name = file.repository_name ?? "";
|
||||
if (!buckets.has(name)) {
|
||||
buckets.set(name, []);
|
||||
order.push(name);
|
||||
}
|
||||
buckets.get(name)!.push(file);
|
||||
}
|
||||
return order.map((name) => ({ repositoryName: name, files: buckets.get(name)! }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky per-repository section header rendered above each group of files in
|
||||
* the changes panel. Only shown when the panel actually has multiple repos
|
||||
* (or any single named repo) — see ReviewDiffList for the gating logic.
|
||||
* the changes panel. The grouping logic lives in @/lib/group-by-repo so the
|
||||
* Review dialog and the dockview Changes tab share the same Map-based
|
||||
* bucketer. Only this presentational header is review-specific.
|
||||
*/
|
||||
export function RepoGroupHeader({ name, fileCount }: { name: string; fileCount: number }) {
|
||||
// The empty-name group ("uncategorised" — no repository_name on its files)
|
||||
|
||||
@@ -31,7 +31,8 @@ import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import { diffSkipReasonLabel } from "./types";
|
||||
import type { ReviewFile } from "./types";
|
||||
import { groupFilesByRepository, RepoGroupHeader } from "./review-diff-list-groups";
|
||||
import { RepoGroupHeader } from "./review-diff-list-groups";
|
||||
import { groupByRepositoryName } from "@/lib/group-by-repo";
|
||||
|
||||
function isMarkdownPath(filePath: string): boolean {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase();
|
||||
@@ -70,7 +71,10 @@ export const ReviewDiffList = memo(function ReviewDiffList({
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
// Find index of selected file - we need to force-load all files up to it
|
||||
const selectedIndex = selectedFile ? files.findIndex((f) => f.path === selectedFile) : -1;
|
||||
const groups = useMemo(() => groupFilesByRepository(files), [files]);
|
||||
const groups = useMemo(
|
||||
() => groupByRepositoryName(files, (f) => f.repository_name),
|
||||
[files],
|
||||
);
|
||||
const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== "";
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="overflow-y-auto h-full">
|
||||
@@ -81,9 +85,9 @@ export const ReviewDiffList = memo(function ReviewDiffList({
|
||||
data-repository-name={group.repositoryName || ""}
|
||||
>
|
||||
{showRepoHeaders && (
|
||||
<RepoGroupHeader name={group.repositoryName} fileCount={group.files.length} />
|
||||
<RepoGroupHeader name={group.repositoryName} fileCount={group.items.length} />
|
||||
)}
|
||||
{group.files.map((file) => (
|
||||
{group.items.map((file) => (
|
||||
<FileDiffSection
|
||||
key={`${group.repositoryName}:${file.path}`}
|
||||
file={file}
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
import { CreateEditSelectors } from "@/components/task-create-dialog-form-body";
|
||||
import {
|
||||
AgentSelector,
|
||||
BranchSelector,
|
||||
ExecutorProfileSelector,
|
||||
} from "@/components/task-create-dialog-selectors";
|
||||
import {
|
||||
useAgentProfileOptions,
|
||||
useBranchOptions,
|
||||
} from "@/components/task-create-dialog-options";
|
||||
import { ExtraRepositoryRows } from "@/components/task-create-dialog-extra-repos";
|
||||
import { useAgentProfileOptions } from "@/components/task-create-dialog-options";
|
||||
|
||||
type CreateModeSelectorsProps = {
|
||||
isTaskStarted: boolean;
|
||||
hasRepositorySelection: boolean;
|
||||
branchOptions: ReturnType<typeof useBranchOptions>;
|
||||
branchesLoading: boolean;
|
||||
agentProfileOptions: ReturnType<typeof useAgentProfileOptions>;
|
||||
executorProfileOptions: Array<{
|
||||
value: string;
|
||||
@@ -31,53 +22,33 @@ type CreateModeSelectorsProps = {
|
||||
executorsLoading: boolean;
|
||||
isCreatingSession: boolean;
|
||||
fs: DialogFormState;
|
||||
onBranchChange: (v: string) => void;
|
||||
onAgentProfileChange: (v: string) => void;
|
||||
onExecutorProfileChange: (v: string) => void;
|
||||
isLocalExecutor: boolean;
|
||||
workflowAgentLocked: boolean;
|
||||
repositories: Repository[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Create/edit-mode form body section: branch + agent + executor selectors,
|
||||
* followed by the multi-repo extra-repository rows. Extracted from
|
||||
* task-create-dialog.tsx to keep the parent file under the lint line cap.
|
||||
* Create/edit-mode form body section: agent + executor profile selectors.
|
||||
* Repo and branch selection moved into the chip row above the description
|
||||
* (see RepoChipsRow), so this row no longer carries those controls.
|
||||
*/
|
||||
export function CreateModeSelectors(props: CreateModeSelectorsProps) {
|
||||
return (
|
||||
<>
|
||||
<CreateEditSelectors
|
||||
isTaskStarted={props.isTaskStarted}
|
||||
hasRepositorySelection={props.hasRepositorySelection}
|
||||
branchOptions={props.branchOptions}
|
||||
branch={props.fs.branch}
|
||||
onBranchChange={props.onBranchChange}
|
||||
branchesLoading={props.branchesLoading}
|
||||
localBranchesLoading={props.fs.localBranchesLoading}
|
||||
agentProfiles={props.agentProfiles}
|
||||
agentProfilesLoading={props.agentProfilesLoading}
|
||||
agentProfileOptions={props.agentProfileOptions}
|
||||
agentProfileId={props.fs.agentProfileId}
|
||||
onAgentProfileChange={props.onAgentProfileChange}
|
||||
isCreatingSession={props.isCreatingSession}
|
||||
executorProfileOptions={props.executorProfileOptions}
|
||||
executorProfileId={props.fs.executorProfileId}
|
||||
onExecutorProfileChange={props.onExecutorProfileChange}
|
||||
executorsLoading={props.executorsLoading}
|
||||
isLocalExecutor={props.isLocalExecutor}
|
||||
useGitHubUrl={props.fs.useGitHubUrl}
|
||||
workflowAgentLocked={props.workflowAgentLocked}
|
||||
BranchSelectorComponent={BranchSelector}
|
||||
AgentSelectorComponent={AgentSelector}
|
||||
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
|
||||
/>
|
||||
<ExtraRepositoryRows
|
||||
fs={props.fs}
|
||||
repositories={props.repositories}
|
||||
isTaskStarted={props.isTaskStarted}
|
||||
primarySelected={props.hasRepositorySelection}
|
||||
/>
|
||||
</>
|
||||
<CreateEditSelectors
|
||||
isTaskStarted={props.isTaskStarted}
|
||||
agentProfiles={props.agentProfiles}
|
||||
agentProfilesLoading={props.agentProfilesLoading}
|
||||
agentProfileOptions={props.agentProfileOptions}
|
||||
agentProfileId={props.fs.agentProfileId}
|
||||
onAgentProfileChange={props.onAgentProfileChange}
|
||||
isCreatingSession={props.isCreatingSession}
|
||||
executorProfileOptions={props.executorProfileOptions}
|
||||
executorProfileId={props.fs.executorProfileId}
|
||||
onExecutorProfileChange={props.onExecutorProfileChange}
|
||||
executorsLoading={props.executorsLoading}
|
||||
workflowAgentLocked={props.workflowAgentLocked}
|
||||
AgentSelectorComponent={AgentSelector}
|
||||
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { Repository, Executor, Branch } from "@/lib/types/http";
|
||||
import type { Repository, Executor } from "@/lib/types/http";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import {
|
||||
discoverRepositoriesAction,
|
||||
listLocalRepositoryBranchesAction,
|
||||
} from "@/app/actions/workspaces";
|
||||
import { discoverRepositoriesAction } from "@/app/actions/workspaces";
|
||||
import { getLocalStorage } from "@/lib/local-storage";
|
||||
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
||||
import { listWorkflowSteps } from "@/lib/api/domains/workflow-api";
|
||||
@@ -18,7 +15,6 @@ import type {
|
||||
StoreSelections,
|
||||
TaskCreateEffectsArgs,
|
||||
} from "@/components/task-create-dialog-types";
|
||||
import { autoSelectBranch } from "@/components/task-create-dialog-helpers";
|
||||
|
||||
export function useWorkflowAgentProfileEffect(
|
||||
fs: DialogFormState,
|
||||
@@ -79,25 +75,27 @@ export function useRepositoryAutoSelectEffect(
|
||||
workspaceId: string | null,
|
||||
repositories: Repository[],
|
||||
) {
|
||||
const { repositoryId, selectedLocalRepo, useGitHubUrl, setRepositoryId } = fs;
|
||||
// Auto-seed the unified repos list with the user's last-used repo (or the
|
||||
// workspace's only repo) on open. Skipped when URL mode is active or the
|
||||
// user already has at least one row picked.
|
||||
const { repositories: rows, useGitHubUrl, setRepositories } = fs;
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId || repositoryId || selectedLocalRepo || useGitHubUrl) return;
|
||||
if (!open || !workspaceId || useGitHubUrl) return;
|
||||
const hasPickedRow = rows.some((r) => r.repositoryId || r.localPath);
|
||||
if (hasPickedRow) return;
|
||||
const lastUsedRepoId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_REPOSITORY_ID, null);
|
||||
let pickId: string | null = null;
|
||||
if (lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId)) {
|
||||
void Promise.resolve().then(() => setRepositoryId(lastUsedRepoId));
|
||||
return;
|
||||
pickId = lastUsedRepoId;
|
||||
} else if (repositories.length === 1) {
|
||||
pickId = repositories[0].id;
|
||||
}
|
||||
if (repositories.length === 1)
|
||||
void Promise.resolve().then(() => setRepositoryId(repositories[0].id));
|
||||
}, [
|
||||
open,
|
||||
repositories,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
useGitHubUrl,
|
||||
workspaceId,
|
||||
setRepositoryId,
|
||||
]);
|
||||
if (!pickId) return;
|
||||
const id = pickId;
|
||||
void Promise.resolve().then(() =>
|
||||
setRepositories([{ key: "row-0", repositoryId: id, branch: "" }]),
|
||||
);
|
||||
}, [open, repositories, rows, useGitHubUrl, workspaceId, setRepositories]);
|
||||
}
|
||||
|
||||
export function useDiscoverReposEffect(
|
||||
@@ -147,35 +145,9 @@ export function useDiscoverReposEffect(
|
||||
]);
|
||||
}
|
||||
|
||||
export function useLocalBranchesEffect(
|
||||
fs: DialogFormState,
|
||||
open: boolean,
|
||||
workspaceId: string | null,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading } = fs;
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId || !selectedLocalRepo) return;
|
||||
const repoPath = selectedLocalRepo.path;
|
||||
void Promise.resolve()
|
||||
.then(() => setLocalBranchesLoading(true))
|
||||
.then(() => listLocalRepositoryBranchesAction(workspaceId, repoPath))
|
||||
.then((r) => {
|
||||
setLocalBranches(r.branches);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: "Failed to load branches",
|
||||
description: e instanceof Error ? e.message : "Request failed",
|
||||
variant: "error",
|
||||
});
|
||||
setLocalBranches([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLocalBranchesLoading(false);
|
||||
});
|
||||
}, [open, selectedLocalRepo, toast, workspaceId, setLocalBranches, setLocalBranchesLoading]);
|
||||
}
|
||||
// useLocalBranchesEffect removed: the chip row's RepoChip loads branches per
|
||||
// repo via useRepositoryBranches now (the store dedupes by id), so we don't
|
||||
// need a per-form "primary local repo branches" cache anymore.
|
||||
|
||||
export function useDefaultSelectionsEffect(
|
||||
fs: DialogFormState,
|
||||
@@ -267,35 +239,30 @@ export function useDefaultSelectionsEffect(
|
||||
}, [executorProfileId, executors, setExecutorId]);
|
||||
}
|
||||
|
||||
export function useBranchAutoSelectEffect(fs: DialogFormState, branches: Branch[]) {
|
||||
const { repositoryId, branch, localBranches, githubBranches, useGitHubUrl, setBranch } = fs;
|
||||
/**
|
||||
* Auto-selects a sensible branch in the GitHub URL flow. Per-repo (workspace
|
||||
* or discovered) branch auto-select happens inside RepoChip when a row's
|
||||
* branches load — that keeps the per-row state confined to the chip.
|
||||
*/
|
||||
export function useBranchAutoSelectEffect(fs: DialogFormState) {
|
||||
const { githubBranch, githubBranches, useGitHubUrl, setGitHubBranch, githubPrHeadBranch } = fs;
|
||||
useEffect(() => {
|
||||
if (!repositoryId || branch) return;
|
||||
autoSelectBranch(branches, setBranch);
|
||||
}, [branch, branches, repositoryId, setBranch]);
|
||||
useEffect(() => {
|
||||
if (repositoryId || localBranches.length === 0 || branch) return;
|
||||
autoSelectBranch(localBranches, setBranch);
|
||||
}, [branch, localBranches, repositoryId, setBranch]);
|
||||
const { githubPrHeadBranch } = fs;
|
||||
useEffect(() => {
|
||||
if (!useGitHubUrl || githubBranches.length === 0 || branch) return;
|
||||
// If a PR URL was detected, prefer the PR's head branch.
|
||||
if (!useGitHubUrl || githubBranches.length === 0 || githubBranch) return;
|
||||
if (githubPrHeadBranch) {
|
||||
const prBranch = githubBranches.find((b) => b.name === githubPrHeadBranch);
|
||||
if (prBranch) {
|
||||
setBranch(prBranch.name);
|
||||
setGitHubBranch(prBranch.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// GitHub URL branches are referenced by name only (no remote prefix).
|
||||
// GitHub URL branches are referenced by name only (no remote prefix);
|
||||
// selectPreferredBranch expects origin-prefixed remotes, so pick directly.
|
||||
const preferred =
|
||||
githubBranches.find((b) => b.name === "main") ??
|
||||
githubBranches.find((b) => b.name === "master") ??
|
||||
githubBranches[0];
|
||||
if (preferred) setBranch(preferred.name);
|
||||
}, [branch, githubBranches, useGitHubUrl, setBranch, githubPrHeadBranch]);
|
||||
if (preferred) setGitHubBranch(preferred.name);
|
||||
}, [githubBranch, githubBranches, useGitHubUrl, setGitHubBranch, githubPrHeadBranch]);
|
||||
}
|
||||
|
||||
/** Parse a GitHub URL to extract owner, repo, and optional PR number. Returns null if invalid. */
|
||||
@@ -405,14 +372,13 @@ export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) {
|
||||
}
|
||||
|
||||
export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) {
|
||||
const { open, workspaceId, workflowId, repositories, repositoriesLoading, branches } = args;
|
||||
const { open, workspaceId, workflowId, repositories, repositoriesLoading } = args;
|
||||
const { agentProfiles, executors, workspaceDefaults, toast, workflows } = args;
|
||||
useWorkflowStepsEffect(fs, workflowId);
|
||||
useWorkflowAgentProfileEffect(fs, workflows, agentProfiles);
|
||||
useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories);
|
||||
useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast);
|
||||
useBranchAutoSelectEffect(fs, branches);
|
||||
useLocalBranchesEffect(fs, open, workspaceId, toast);
|
||||
useBranchAutoSelectEffect(fs);
|
||||
useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }, workflows);
|
||||
useGitHubUrlBranchesEffect(fs, open);
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { ExtraRepositoryRow } from "@/components/task-create-dialog-types";
|
||||
|
||||
/**
|
||||
* Manages the extra-repository rows for multi-repo task creation. The primary
|
||||
* repo lives on the form's repositoryId/branch; extras here represent the
|
||||
* 2nd, 3rd, ... rows that the user adds via "+ Add repository".
|
||||
*
|
||||
* `nextKey` increments to give each row a stable client-side key without
|
||||
* relying on array indices (which would shift on removal and break inputs).
|
||||
*/
|
||||
export function useExtraRepositoriesState() {
|
||||
const [extraRepositories, setExtraRepositories] = useState<ExtraRepositoryRow[]>([]);
|
||||
const nextKeyRef = useRef(0);
|
||||
|
||||
const addExtraRepository = useCallback(() => {
|
||||
nextKeyRef.current += 1;
|
||||
const key = `extra-${nextKeyRef.current}`;
|
||||
setExtraRepositories((rows) => [...rows, { key, repositoryId: "", branch: "" }]);
|
||||
}, []);
|
||||
|
||||
const removeExtraRepository = useCallback((key: string) => {
|
||||
setExtraRepositories((rows) => rows.filter((r) => r.key !== key));
|
||||
}, []);
|
||||
|
||||
const updateExtraRepository = useCallback(
|
||||
(key: string, patch: Partial<ExtraRepositoryRow>) => {
|
||||
setExtraRepositories((rows) =>
|
||||
rows.map((r) => (r.key === key ? { ...r, ...patch } : r)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetExtraRepositories = useCallback(() => {
|
||||
setExtraRepositories([]);
|
||||
nextKeyRef.current = 0;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
extraRepositories,
|
||||
addExtraRepository,
|
||||
removeExtraRepository,
|
||||
updateExtraRepository,
|
||||
resetExtraRepositories,
|
||||
};
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState, ExtraRepositoryRow } from "./task-create-dialog-types";
|
||||
|
||||
// Mock branches hook so the BranchSelector inside the row doesn't blow up.
|
||||
// vi.mock is hoisted, so it applies to the static import below.
|
||||
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
|
||||
useRepositoryBranches: () => ({ branches: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
import { ExtraRepositoryRows } from "./task-create-dialog-extra-repos";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const REPO_FRONT_ID = "repo-front";
|
||||
const REPO_BACK_ID = "repo-back";
|
||||
|
||||
function makeRepo(id: string, name: string): Repository {
|
||||
return {
|
||||
id,
|
||||
workspace_id: "ws-1",
|
||||
name,
|
||||
source_type: "local",
|
||||
local_path: `/repos/${name}`,
|
||||
default_branch: "main",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as Repository;
|
||||
}
|
||||
|
||||
function makeFs(overrides: Partial<DialogFormState>): DialogFormState {
|
||||
// Only the fields ExtraRepositoryRows actually reads need to be real.
|
||||
return {
|
||||
repositoryId: REPO_FRONT_ID,
|
||||
extraRepositories: [] as ExtraRepositoryRow[],
|
||||
addExtraRepository: vi.fn(),
|
||||
removeExtraRepository: vi.fn(),
|
||||
updateExtraRepository: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as DialogFormState;
|
||||
}
|
||||
|
||||
describe("ExtraRepositoryRows", () => {
|
||||
it("disables the Add button until a primary repo is selected", async () => {
|
||||
|
||||
render(
|
||||
<ExtraRepositoryRows
|
||||
fs={makeFs({})}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted={false}
|
||||
primarySelected={false}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByTestId("add-extra-repository") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls addExtraRepository when the Add button is clicked", async () => {
|
||||
|
||||
const fs = makeFs({});
|
||||
render(
|
||||
<ExtraRepositoryRows
|
||||
fs={fs}
|
||||
// Need a second repo so the Add button is enabled (the primary
|
||||
// would otherwise be the only choice).
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
|
||||
isTaskStarted={false}
|
||||
primarySelected
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("add-extra-repository"));
|
||||
expect(fs.addExtraRepository).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders one editable row per extra repository", async () => {
|
||||
|
||||
render(
|
||||
<ExtraRepositoryRows
|
||||
fs={makeFs({
|
||||
extraRepositories: [
|
||||
{ key: "extra-1", repositoryId: "", branch: "" },
|
||||
{ key: "extra-2", repositoryId: "", branch: "" },
|
||||
],
|
||||
})}
|
||||
repositories={[
|
||||
makeRepo(REPO_FRONT_ID, "frontend"),
|
||||
makeRepo(REPO_BACK_ID, "backend"),
|
||||
makeRepo("repo-shared", "shared"),
|
||||
]}
|
||||
isTaskStarted={false}
|
||||
primarySelected
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId("extra-repository-row")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides the section when the task is already started", async () => {
|
||||
|
||||
const { container } = render(
|
||||
<ExtraRepositoryRows
|
||||
fs={makeFs({
|
||||
extraRepositories: [{ key: "extra-1", repositoryId: "", branch: "" }],
|
||||
})}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted
|
||||
primarySelected
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("[data-testid='extra-repositories']")).toBeNull();
|
||||
});
|
||||
|
||||
it("opening a row's dropdown shows the unused workspace repos", async () => {
|
||||
|
||||
render(
|
||||
<ExtraRepositoryRows
|
||||
fs={makeFs({
|
||||
repositoryId: REPO_FRONT_ID,
|
||||
extraRepositories: [{ key: "extra-1", repositoryId: "", branch: "" }],
|
||||
})}
|
||||
repositories={[
|
||||
makeRepo(REPO_FRONT_ID, "frontend"),
|
||||
makeRepo(REPO_BACK_ID, "backend"),
|
||||
makeRepo("repo-shared", "shared"),
|
||||
]}
|
||||
isTaskStarted={false}
|
||||
primarySelected
|
||||
/>,
|
||||
);
|
||||
// Open the row's repository combobox.
|
||||
const triggers = screen.getAllByTestId("repository-selector");
|
||||
fireEvent.click(triggers[0]);
|
||||
// The primary repo (frontend) is excluded; the other two should be offered.
|
||||
expect(screen.queryByText("backend")).not.toBeNull();
|
||||
expect(screen.queryByText("shared")).not.toBeNull();
|
||||
expect(screen.queryByText("frontend")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the Add button when no other workspace repos are available", async () => {
|
||||
|
||||
render(
|
||||
<ExtraRepositoryRows
|
||||
fs={makeFs({ repositoryId: "repo-only" })}
|
||||
repositories={[makeRepo("repo-only", "only")]}
|
||||
isTaskStarted={false}
|
||||
primarySelected
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByTestId("add-extra-repository") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { IconPlus, IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { RepositorySelector, BranchSelector } from "@/components/task-create-dialog-selectors";
|
||||
import { useRepositoryBranches } from "@/hooks/domains/workspace/use-repository-branches";
|
||||
import { useBranchOptions, useRepositoryOptions } from "@/components/task-create-dialog-options";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type {
|
||||
ExtraRepositoryRow,
|
||||
DialogFormState,
|
||||
} from "@/components/task-create-dialog-types";
|
||||
|
||||
type ExtraRepositoryRowsProps = {
|
||||
fs: DialogFormState;
|
||||
repositories: Repository[];
|
||||
isTaskStarted: boolean;
|
||||
/**
|
||||
* Disable the Add button when no primary repository is selected — extra
|
||||
* rows only make sense when the task has at least one repo defined.
|
||||
*/
|
||||
primarySelected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the "+ Add repository" control plus one editable row per extra
|
||||
* repo on the task. The primary repo is rendered elsewhere (header +
|
||||
* CreateEditSelectors); this component only handles the 2nd, 3rd, …
|
||||
*
|
||||
* Each row is a pair of (repository combobox, base-branch combobox).
|
||||
* Branches for the chosen repo are fetched on demand via the existing
|
||||
* useRepositoryBranches hook so the user gets the right list per repo.
|
||||
*/
|
||||
export function ExtraRepositoryRows({
|
||||
fs,
|
||||
repositories,
|
||||
isTaskStarted,
|
||||
primarySelected,
|
||||
}: ExtraRepositoryRowsProps) {
|
||||
// Hide the section in started/edit modes where the repo set is fixed.
|
||||
if (isTaskStarted) return null;
|
||||
|
||||
// Count workspace repositories the user could still add: total minus primary
|
||||
// minus all already-chosen extras. When zero, we disable the Add button so
|
||||
// the user gets a clear signal instead of a row with an empty dropdown.
|
||||
const usedIds = collectUsedRepoIds(fs, "");
|
||||
const remainingCount = repositories.filter((r) => !usedIds.has(r.id)).length;
|
||||
const canAddMore = primarySelected && remainingCount > 0;
|
||||
const addButtonHint = computeAddButtonHint(primarySelected, remainingCount);
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="extra-repositories">
|
||||
{fs.extraRepositories.map((row) => (
|
||||
<ExtraRepositoryRowItem
|
||||
key={row.key}
|
||||
row={row}
|
||||
repositories={repositories}
|
||||
// Disallow picking the primary repo or any other extra row's repo.
|
||||
excludedRepoIds={collectUsedRepoIds(fs, row.key)}
|
||||
onRepositoryChange={(value) => fs.updateExtraRepository(row.key, { repositoryId: value })}
|
||||
onBranchChange={(value) => fs.updateExtraRepository(row.key, { branch: value })}
|
||||
onRemove={() => fs.removeExtraRepository(row.key)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fs.addExtraRepository}
|
||||
disabled={!canAddMore}
|
||||
title={addButtonHint}
|
||||
className="text-xs cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
data-testid="add-extra-repository"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5 mr-1" />
|
||||
Add repository
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeAddButtonHint(
|
||||
primarySelected: boolean,
|
||||
remainingCount: number,
|
||||
): string | undefined {
|
||||
if (!primarySelected) return "Select the primary repository first";
|
||||
if (remainingCount === 0) return "All workspace repositories are already added";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function computeBranchPlaceholder(
|
||||
repoId: string,
|
||||
branchesLoading: boolean,
|
||||
branchCount: number,
|
||||
): string {
|
||||
if (!repoId) return "Select repository first";
|
||||
if (branchesLoading) return "Loading branches...";
|
||||
if (branchCount === 0) return "No branches found";
|
||||
return "Select branch";
|
||||
}
|
||||
|
||||
function collectUsedRepoIds(fs: DialogFormState, exceptKey: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (fs.repositoryId) ids.add(fs.repositoryId);
|
||||
for (const r of fs.extraRepositories) {
|
||||
if (r.key !== exceptKey && r.repositoryId) ids.add(r.repositoryId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
type ExtraRepositoryRowItemProps = {
|
||||
row: ExtraRepositoryRow;
|
||||
repositories: Repository[];
|
||||
excludedRepoIds: Set<string>;
|
||||
onRepositoryChange: (value: string) => void;
|
||||
onBranchChange: (value: string) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function ExtraRepositoryRowItem({
|
||||
row,
|
||||
repositories,
|
||||
excludedRepoIds,
|
||||
onRepositoryChange,
|
||||
onBranchChange,
|
||||
onRemove,
|
||||
}: ExtraRepositoryRowItemProps) {
|
||||
// Filter the repo list to drop already-used repos (prevents duplicates
|
||||
// before the user even sees them in the dropdown).
|
||||
const filteredRepos = useMemo(
|
||||
() => repositories.filter((r) => !excludedRepoIds.has(r.id)),
|
||||
[repositories, excludedRepoIds],
|
||||
);
|
||||
const { headerRepositoryOptions } = useRepositoryOptions(filteredRepos, []);
|
||||
const { branches, isLoading: branchesLoading } = useRepositoryBranches(
|
||||
row.repositoryId || null,
|
||||
Boolean(row.repositoryId),
|
||||
);
|
||||
const branchOptions = useBranchOptions(branches);
|
||||
|
||||
const branchPlaceholder = computeBranchPlaceholder(
|
||||
row.repositoryId,
|
||||
branchesLoading,
|
||||
branchOptions.length,
|
||||
);
|
||||
|
||||
const handleRepoChange = useCallback(
|
||||
(value: string) => {
|
||||
onRepositoryChange(value);
|
||||
// Reset branch when the repo changes — the previous branch may not exist
|
||||
// on the new repo, and stale text would mislead the user.
|
||||
onBranchChange("");
|
||||
},
|
||||
[onRepositoryChange, onBranchChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
data-testid="extra-repository-row"
|
||||
data-row-key={row.key}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<RepositorySelector
|
||||
options={headerRepositoryOptions}
|
||||
value={row.repositoryId}
|
||||
onValueChange={handleRepoChange}
|
||||
disabled={false}
|
||||
placeholder="Select repository"
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage={
|
||||
filteredRepos.length === 0 ? "No more repositories available." : "No repositories found."
|
||||
}
|
||||
triggerClassName="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<BranchSelector
|
||||
options={branchOptions}
|
||||
value={row.branch}
|
||||
onValueChange={onBranchChange}
|
||||
disabled={!row.repositoryId || branchesLoading || branchOptions.length === 0}
|
||||
placeholder={branchPlaceholder}
|
||||
searchPlaceholder="Search branches..."
|
||||
emptyMessage="No branch found."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove repository"
|
||||
data-testid="remove-extra-repository"
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ function makeProps(
|
||||
hasTitle: true,
|
||||
hasDescription: true,
|
||||
hasRepositorySelection: true,
|
||||
branch: "main",
|
||||
hasAllBranches: true,
|
||||
agentProfileId: "agent-1",
|
||||
workspaceId: "ws-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
@@ -69,7 +69,9 @@ describe("computeDisabledReason (start-task)", () => {
|
||||
});
|
||||
|
||||
it("flags missing branch", () => {
|
||||
expect(computeDisabledReason(makeProps({ branch: "" }), KIND_START)).toBe(REASON_BRANCH);
|
||||
expect(computeDisabledReason(makeProps({ hasAllBranches: false }), KIND_START)).toBe(
|
||||
REASON_BRANCH,
|
||||
);
|
||||
});
|
||||
|
||||
it("flags missing workspace in create mode", () => {
|
||||
@@ -157,7 +159,7 @@ describe("computeDisabledReason (default)", () => {
|
||||
isSessionMode: true,
|
||||
hasTitle: false,
|
||||
hasRepositorySelection: false,
|
||||
branch: "",
|
||||
hasAllBranches: false,
|
||||
}),
|
||||
KIND_DEFAULT,
|
||||
),
|
||||
|
||||
@@ -216,7 +216,12 @@ export type TaskCreateDialogFooterProps = {
|
||||
hasTitle: boolean;
|
||||
hasDescription: boolean;
|
||||
hasRepositorySelection: boolean;
|
||||
branch: string;
|
||||
/**
|
||||
* True when every selected repo has a base branch picked (and the URL flow's
|
||||
* branch, if active). Was previously `branch: string` for the single-repo
|
||||
* primary; now aggregated upstream because each row has its own branch.
|
||||
*/
|
||||
hasAllBranches: boolean;
|
||||
agentProfileId: string;
|
||||
workspaceId: string | null;
|
||||
effectiveWorkflowId: string | null;
|
||||
@@ -245,7 +250,7 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) {
|
||||
props.isCreatingTask ||
|
||||
!props.hasTitle ||
|
||||
!props.hasRepositorySelection ||
|
||||
!props.branch ||
|
||||
!props.hasAllBranches ||
|
||||
missingCtx
|
||||
);
|
||||
}
|
||||
@@ -263,7 +268,7 @@ export const REASON_DESCRIPTION = "Add a session description";
|
||||
function baseReason(props: TaskCreateDialogFooterProps): string | null {
|
||||
if (!props.hasTitle) return REASON_TITLE;
|
||||
if (!props.hasRepositorySelection) return REASON_REPO;
|
||||
if (!props.branch) return REASON_BRANCH;
|
||||
if (!props.hasAllBranches) return REASON_BRANCH;
|
||||
if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE;
|
||||
if (props.isCreateMode && !props.effectiveWorkflowId) return REASON_WORKFLOW;
|
||||
return null;
|
||||
|
||||
@@ -19,12 +19,6 @@ type SelectorOption = {
|
||||
|
||||
type CreateEditSelectorsProps = {
|
||||
isTaskStarted: boolean;
|
||||
hasRepositorySelection: boolean;
|
||||
branchOptions: SelectorOption[];
|
||||
branch: string;
|
||||
onBranchChange: (value: string) => void;
|
||||
branchesLoading: boolean;
|
||||
localBranchesLoading: boolean;
|
||||
agentProfiles: AgentProfileOption[];
|
||||
agentProfilesLoading: boolean;
|
||||
agentProfileOptions: SelectorOption[];
|
||||
@@ -39,15 +33,6 @@ type CreateEditSelectorsProps = {
|
||||
executorProfileId: string;
|
||||
onExecutorProfileChange: (value: string) => void;
|
||||
executorsLoading: boolean;
|
||||
BranchSelectorComponent: React.ComponentType<{
|
||||
options: SelectorOption[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyMessage: string;
|
||||
}>;
|
||||
AgentSelectorComponent: React.ComponentType<{
|
||||
options: SelectorOption[];
|
||||
value: string;
|
||||
@@ -64,19 +49,11 @@ type CreateEditSelectorsProps = {
|
||||
placeholder: string;
|
||||
triggerClassName?: string;
|
||||
}>;
|
||||
isLocalExecutor: boolean;
|
||||
useGitHubUrl: boolean;
|
||||
workflowAgentLocked: boolean;
|
||||
};
|
||||
|
||||
export const CreateEditSelectors = memo(function CreateEditSelectors({
|
||||
isTaskStarted,
|
||||
hasRepositorySelection,
|
||||
branchOptions,
|
||||
branch,
|
||||
onBranchChange,
|
||||
branchesLoading,
|
||||
localBranchesLoading,
|
||||
agentProfiles,
|
||||
agentProfilesLoading,
|
||||
agentProfileOptions,
|
||||
@@ -87,10 +64,7 @@ export const CreateEditSelectors = memo(function CreateEditSelectors({
|
||||
executorProfileId,
|
||||
onExecutorProfileChange,
|
||||
executorsLoading,
|
||||
isLocalExecutor,
|
||||
useGitHubUrl,
|
||||
workflowAgentLocked,
|
||||
BranchSelectorComponent,
|
||||
AgentSelectorComponent,
|
||||
ExecutorProfileSelectorComponent,
|
||||
}: CreateEditSelectorsProps) {
|
||||
@@ -98,41 +72,16 @@ export const CreateEditSelectors = memo(function CreateEditSelectors({
|
||||
|
||||
const agentLockedByWorkflow = workflowAgentLocked;
|
||||
|
||||
const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl;
|
||||
|
||||
const branchPlaceholder = (() => {
|
||||
if (isLocalWithoutGitHubUrl) return "Uses current branch";
|
||||
if (!hasRepositorySelection) return "Select repository first";
|
||||
if (branchesLoading || localBranchesLoading) return "Loading branches...";
|
||||
return branchOptions.length > 0 ? "Select branch" : "No branches found";
|
||||
})();
|
||||
|
||||
const branchDisabled =
|
||||
isLocalWithoutGitHubUrl ||
|
||||
!hasRepositorySelection ||
|
||||
branchesLoading ||
|
||||
localBranchesLoading ||
|
||||
branchOptions.length === 0;
|
||||
|
||||
const agentPlaceholder = (() => {
|
||||
if (agentProfilesLoading) return "Loading agents...";
|
||||
if (agentProfiles.length === 0) return "No agents available";
|
||||
return "Select agent";
|
||||
})();
|
||||
|
||||
// Branch + repo selection moved into the chip row above the description;
|
||||
// this row carries only agent and executor profile selectors now.
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
<div>
|
||||
<BranchSelectorComponent
|
||||
options={branchOptions}
|
||||
value={branch}
|
||||
onValueChange={onBranchChange}
|
||||
placeholder={branchPlaceholder}
|
||||
searchPlaceholder="Search branches..."
|
||||
emptyMessage="No branch found."
|
||||
disabled={branchDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
||||
<div>
|
||||
{agentProfiles.length === 0 && !agentProfilesLoading ? (
|
||||
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
|
||||
|
||||
@@ -2,43 +2,42 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
|
||||
import { setLocalStorage } from "@/lib/local-storage";
|
||||
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
||||
|
||||
/**
|
||||
* Centralizes form-field change handlers for the task-create dialog. Lives
|
||||
* in its own file so the main state hook stays under the file-length lint cap.
|
||||
* Centralizes form-field change handlers for the task-create dialog.
|
||||
*
|
||||
* The dialog now stores all repos in a single `fs.repositories` list (no
|
||||
* "primary vs extras" split), so the per-row handlers are uniform: changing
|
||||
* a repo on row N is the same op whether N==0 or N==5.
|
||||
*/
|
||||
export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) {
|
||||
const handleSelectLocalRepository = useCallback(
|
||||
(path: string) => {
|
||||
fs.setDiscoveredRepoPath(path);
|
||||
fs.setSelectedLocalRepo(fs.discoveredRepositories.find((r) => r.path === path) ?? null);
|
||||
fs.setRepositoryId("");
|
||||
fs.setBranch("");
|
||||
fs.setLocalBranches([]);
|
||||
/**
|
||||
* Resolves a picker value into the right shape for a row:
|
||||
* - If it matches a workspace repo id → `{ repositoryId, localPath: undefined }`.
|
||||
* - Otherwise treat as a discovered on-machine path → `{ localPath, repositoryId: undefined }`.
|
||||
* The branch is reset because the previous branch may not exist on the new repo.
|
||||
*/
|
||||
const handleRowRepositoryChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const isWorkspaceRepo = repositories.some((r: Repository) => r.id === value);
|
||||
const patch: Partial<TaskRepoRow> = isWorkspaceRepo
|
||||
? { repositoryId: value, localPath: undefined, branch: "" }
|
||||
: { repositoryId: undefined, localPath: value, branch: "" };
|
||||
fs.updateRepository(key, patch);
|
||||
if (isWorkspaceRepo) setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value);
|
||||
},
|
||||
[fs],
|
||||
[repositories, fs],
|
||||
);
|
||||
|
||||
const handleRepositoryChange = useCallback(
|
||||
(value: string) => {
|
||||
if (repositories.find((r: Repository) => r.id === value)) {
|
||||
fs.setRepositoryId(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value);
|
||||
fs.setDiscoveredRepoPath("");
|
||||
fs.setSelectedLocalRepo(null);
|
||||
fs.setLocalBranches([]);
|
||||
fs.setBranch("");
|
||||
fs.setUseGitHubUrl(false);
|
||||
fs.setGitHubUrl("");
|
||||
fs.setGitHubBranches([]);
|
||||
return;
|
||||
}
|
||||
handleSelectLocalRepository(value);
|
||||
const handleRowBranchChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
fs.updateRepository(key, { branch: value });
|
||||
setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value);
|
||||
},
|
||||
[repositories, fs, handleSelectLocalRepository],
|
||||
[fs],
|
||||
);
|
||||
|
||||
const handleAgentProfileChange = useCallback(
|
||||
@@ -62,9 +61,9 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleBranchChange = useCallback(
|
||||
const handleGitHubBranchChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setBranch(value);
|
||||
fs.setGitHubBranch(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value);
|
||||
},
|
||||
[fs],
|
||||
@@ -76,27 +75,31 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[
|
||||
[fs],
|
||||
);
|
||||
|
||||
/**
|
||||
* Toggles between "repo chips" mode and "GitHub URL" mode. URL mode
|
||||
* replaces the chip row with a single URL input; flipping back restores
|
||||
* the chip flow with whatever rows were already there.
|
||||
*/
|
||||
const handleToggleGitHubUrl = useCallback(() => {
|
||||
const next = !fs.useGitHubUrl;
|
||||
fs.setUseGitHubUrl(next);
|
||||
if (next) {
|
||||
fs.setRepositoryId("");
|
||||
fs.setDiscoveredRepoPath("");
|
||||
fs.setSelectedLocalRepo(null);
|
||||
fs.setLocalBranches([]);
|
||||
// Stash the rows aside while in URL mode; flipping back keeps them.
|
||||
// (Nothing to do here — chips re-render off `fs.repositories` once
|
||||
// useGitHubUrl flips back to false.)
|
||||
} else {
|
||||
fs.setGitHubUrl("");
|
||||
fs.setGitHubBranches([]);
|
||||
fs.setGitHubUrlError(null);
|
||||
fs.setGitHubPrHeadBranch(null);
|
||||
}
|
||||
fs.setBranch("");
|
||||
fs.setGitHubBranch("");
|
||||
}, [fs]);
|
||||
|
||||
const handleGitHubUrlChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setGitHubUrl(value);
|
||||
fs.setBranch("");
|
||||
fs.setGitHubBranch("");
|
||||
fs.setGitHubBranches([]);
|
||||
fs.setGitHubUrlError(null);
|
||||
fs.setGitHubPrHeadBranch(null);
|
||||
@@ -105,11 +108,12 @@ export function useDialogHandlers(fs: DialogFormState, repositories: Repository[
|
||||
);
|
||||
|
||||
return {
|
||||
handleRepositoryChange,
|
||||
handleRowRepositoryChange,
|
||||
handleRowBranchChange,
|
||||
handleAgentProfileChange,
|
||||
handleExecutorProfileChange,
|
||||
handleTaskNameChange,
|
||||
handleBranchChange,
|
||||
handleGitHubBranchChange,
|
||||
handleWorkflowChange,
|
||||
handleToggleGitHubUrl,
|
||||
handleGitHubUrlChange,
|
||||
|
||||
@@ -1,68 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildRepositoriesPayload } from "./task-create-dialog-helpers";
|
||||
|
||||
describe("buildRepositoriesPayload — multi-repo", () => {
|
||||
it("appends extra repositories after the primary in order", () => {
|
||||
describe("buildRepositoriesPayload — unified rows", () => {
|
||||
it("maps each row in order, dropping empty ones silently", () => {
|
||||
const payload = buildRepositoriesPayload({
|
||||
useGitHubUrl: false,
|
||||
githubUrl: "",
|
||||
branch: "main",
|
||||
githubBranch: "",
|
||||
githubPrHeadBranch: null,
|
||||
repositoryId: "repo-front",
|
||||
selectedLocalRepo: null,
|
||||
extraRepositories: [
|
||||
{ repositoryId: "repo-back", branch: "develop" },
|
||||
{ repositoryId: "repo-shared", branch: "" },
|
||||
repositories: [
|
||||
{ key: "r0", repositoryId: "repo-front", branch: "main" },
|
||||
{ key: "r1", repositoryId: "repo-back", branch: "develop" },
|
||||
{ key: "r2", branch: "" }, // no repo picked yet — dropped
|
||||
{ key: "r3", repositoryId: "repo-shared", branch: "" },
|
||||
],
|
||||
discoveredRepositories: [],
|
||||
});
|
||||
expect(payload).toEqual([
|
||||
{ repository_id: "repo-front", base_branch: "main", checkout_branch: undefined },
|
||||
{ repository_id: "repo-front", base_branch: "main" },
|
||||
{ repository_id: "repo-back", base_branch: "develop" },
|
||||
{ repository_id: "repo-shared", base_branch: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops extra rows with empty repository_id", () => {
|
||||
it("emits local_path + default_branch for discovered (on-machine) rows", () => {
|
||||
const payload = buildRepositoriesPayload({
|
||||
useGitHubUrl: false,
|
||||
githubUrl: "",
|
||||
branch: "main",
|
||||
githubBranch: "",
|
||||
githubPrHeadBranch: null,
|
||||
repositoryId: "repo-front",
|
||||
selectedLocalRepo: null,
|
||||
extraRepositories: [
|
||||
{ repositoryId: "", branch: "" },
|
||||
{ repositoryId: "repo-back", branch: "main" },
|
||||
repositories: [
|
||||
{ key: "r0", localPath: "/home/me/projects/local-project", branch: "trunk" },
|
||||
{ key: "r1", repositoryId: "repo-back", branch: "main" },
|
||||
],
|
||||
});
|
||||
expect(payload).toHaveLength(2);
|
||||
expect(payload[1].repository_id).toBe("repo-back");
|
||||
});
|
||||
|
||||
it("returns extras even when no primary is set (recovery shape)", () => {
|
||||
const payload = buildRepositoriesPayload({
|
||||
useGitHubUrl: false,
|
||||
githubUrl: "",
|
||||
branch: "",
|
||||
githubPrHeadBranch: null,
|
||||
repositoryId: "",
|
||||
selectedLocalRepo: null,
|
||||
extraRepositories: [{ repositoryId: "repo-x", branch: "main" }],
|
||||
});
|
||||
expect(payload).toEqual([{ repository_id: "repo-x", base_branch: "main" }]);
|
||||
});
|
||||
|
||||
it("single-repo single call: behaves identically to before extraRepositories was added", () => {
|
||||
const payload = buildRepositoriesPayload({
|
||||
useGitHubUrl: false,
|
||||
githubUrl: "",
|
||||
branch: "main",
|
||||
githubPrHeadBranch: null,
|
||||
repositoryId: "repo-only",
|
||||
selectedLocalRepo: null,
|
||||
discoveredRepositories: [
|
||||
{ path: "/home/me/projects/local-project", default_branch: "trunk" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any,
|
||||
});
|
||||
expect(payload).toEqual([
|
||||
{ repository_id: "repo-only", base_branch: "main", checkout_branch: undefined },
|
||||
{
|
||||
repository_id: "",
|
||||
base_branch: "trunk",
|
||||
local_path: "/home/me/projects/local-project",
|
||||
default_branch: "trunk",
|
||||
},
|
||||
{ repository_id: "repo-back", base_branch: "main" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("URL mode produces a single github_url entry and ignores the rows", () => {
|
||||
const payload = buildRepositoriesPayload({
|
||||
useGitHubUrl: true,
|
||||
githubUrl: "github.com/owner/repo",
|
||||
githubBranch: "feature-x",
|
||||
githubPrHeadBranch: null,
|
||||
repositories: [{ key: "r0", repositoryId: "ignored", branch: "ignored" }],
|
||||
discoveredRepositories: [],
|
||||
});
|
||||
expect(payload).toEqual([
|
||||
{
|
||||
repository_id: "",
|
||||
base_branch: "feature-x",
|
||||
checkout_branch: undefined,
|
||||
github_url: "github.com/owner/repo",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("single-row workspace repo: payload mirrors the row", () => {
|
||||
const payload = buildRepositoriesPayload({
|
||||
useGitHubUrl: false,
|
||||
githubUrl: "",
|
||||
githubBranch: "",
|
||||
githubPrHeadBranch: null,
|
||||
repositories: [{ key: "r0", repositoryId: "repo-only", branch: "main" }],
|
||||
discoveredRepositories: [],
|
||||
});
|
||||
expect(payload).toEqual([{ repository_id: "repo-only", base_branch: "main" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { useRouter } from "next/navigation";
|
||||
import type { Task, Branch, LocalRepository } from "@/lib/types/http";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { StepType } from "@/components/task-create-dialog-types";
|
||||
import type { StepType, TaskRepoRow } from "@/components/task-create-dialog-types";
|
||||
import { selectPreferredBranch } from "@/lib/utils";
|
||||
import { getLocalStorage } from "@/lib/local-storage";
|
||||
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
||||
@@ -138,93 +138,66 @@ export function validateCreateInputs(inputs: {
|
||||
trimmedTitle: string;
|
||||
workspaceId: string | null;
|
||||
effectiveWorkflowId: string | null;
|
||||
repositoryId: string;
|
||||
selectedLocalRepo: LocalRepository | null;
|
||||
/** Unified repos list. The form is valid if any row has a repo set OR URL mode is filled. */
|
||||
repositories: TaskRepoRow[];
|
||||
githubUrl?: string;
|
||||
agentProfileId: string;
|
||||
}): boolean {
|
||||
const hasRepo =
|
||||
inputs.repositories.some((r) => r.repositoryId || r.localPath) ||
|
||||
Boolean(inputs.githubUrl?.trim());
|
||||
return Boolean(
|
||||
inputs.trimmedTitle &&
|
||||
inputs.workspaceId &&
|
||||
inputs.effectiveWorkflowId &&
|
||||
inputs.agentProfileId &&
|
||||
(inputs.repositoryId || inputs.selectedLocalRepo || inputs.githubUrl?.trim()),
|
||||
inputs.workspaceId &&
|
||||
inputs.effectiveWorkflowId &&
|
||||
inputs.agentProfileId &&
|
||||
hasRepo,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One additional repository row from the multi-repo task create form.
|
||||
* The primary repo is built from useGitHubUrl/repositoryId/selectedLocalRepo
|
||||
* fields (see buildRepositoriesPayload below); these extras are appended.
|
||||
* Builds the repositories payload for task creation from the unified list.
|
||||
*
|
||||
* - URL mode produces a single entry with `github_url`.
|
||||
* - Otherwise each row maps to either a workspace `repository_id` or a
|
||||
* discovered `local_path`. Empty rows are dropped silently so a user
|
||||
* can leave an unfinished chip without blocking submit; duplicate
|
||||
* detection happens on the backend.
|
||||
*/
|
||||
export type ExtraRepoInput = {
|
||||
repositoryId: string;
|
||||
branch: string;
|
||||
};
|
||||
|
||||
/** Builds the repositories payload for task creation from the current form state. */
|
||||
export function buildRepositoriesPayload(opts: {
|
||||
useGitHubUrl: boolean;
|
||||
githubUrl: string;
|
||||
branch: string;
|
||||
githubBranch: string;
|
||||
githubPrHeadBranch: string | null;
|
||||
repositoryId: string;
|
||||
selectedLocalRepo: LocalRepository | null;
|
||||
/**
|
||||
* Additional repository rows entered via the "+ Add repository" button.
|
||||
* Empty rows (no repository_id) are dropped silently so a user can leave
|
||||
* an unfinished row without blocking submit; duplicate detection happens
|
||||
* at the backend (see Phase 8).
|
||||
*/
|
||||
extraRepositories?: ExtraRepoInput[];
|
||||
}): NonNullable<CreateTaskParams["repositories"]> {
|
||||
const primary = buildPrimaryRepoPayload(opts);
|
||||
const extras = (opts.extraRepositories ?? [])
|
||||
.filter((row) => row.repositoryId.trim() !== "")
|
||||
.map((row) => ({
|
||||
repository_id: row.repositoryId,
|
||||
base_branch: row.branch || undefined,
|
||||
}));
|
||||
if (primary.length === 0) return extras;
|
||||
return [...primary, ...extras];
|
||||
}
|
||||
|
||||
function buildPrimaryRepoPayload(opts: {
|
||||
useGitHubUrl: boolean;
|
||||
githubUrl: string;
|
||||
branch: string;
|
||||
githubPrHeadBranch: string | null;
|
||||
repositoryId: string;
|
||||
selectedLocalRepo: LocalRepository | null;
|
||||
repositories: TaskRepoRow[];
|
||||
/** Used to look up `default_branch` for `localPath` rows. */
|
||||
discoveredRepositories: LocalRepository[];
|
||||
}): NonNullable<CreateTaskParams["repositories"]> {
|
||||
if (opts.useGitHubUrl && opts.githubUrl) {
|
||||
return [
|
||||
{
|
||||
repository_id: "",
|
||||
base_branch: opts.branch || undefined,
|
||||
base_branch: opts.githubBranch || undefined,
|
||||
checkout_branch: opts.githubPrHeadBranch || undefined,
|
||||
github_url: opts.githubUrl.trim(),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (opts.repositoryId) {
|
||||
return [
|
||||
{
|
||||
repository_id: opts.repositoryId,
|
||||
base_branch: opts.branch || undefined,
|
||||
checkout_branch: opts.githubPrHeadBranch || undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (opts.selectedLocalRepo) {
|
||||
return [
|
||||
{
|
||||
return opts.repositories
|
||||
.filter((row) => row.repositoryId || row.localPath)
|
||||
.map((row) => {
|
||||
if (row.repositoryId) {
|
||||
return {
|
||||
repository_id: row.repositoryId,
|
||||
base_branch: row.branch || undefined,
|
||||
};
|
||||
}
|
||||
const local = opts.discoveredRepositories.find((d) => d.path === row.localPath);
|
||||
return {
|
||||
repository_id: "",
|
||||
base_branch: opts.branch || undefined,
|
||||
local_path: opts.selectedLocalRepo.path,
|
||||
default_branch: opts.selectedLocalRepo.default_branch || undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
base_branch: row.branch || undefined,
|
||||
local_path: row.localPath,
|
||||
default_branch: local?.default_branch || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Branch, Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types";
|
||||
|
||||
// One mocked branches hook now — id-based and path-based rows go through
|
||||
// the same loader. Tests can override the return value when they need
|
||||
// branch-specific assertions; the mockBySource handle lets a test prove
|
||||
// which kind of source the chip passed in.
|
||||
const lastBranchSource = vi.hoisted((): { value: unknown } => ({ value: null }));
|
||||
const mockBranches = vi.hoisted(
|
||||
(): { value: { branches: Branch[]; isLoading: boolean } } => ({
|
||||
value: { branches: [], isLoading: false },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
|
||||
useBranches: (source: unknown) => {
|
||||
lastBranchSource.value = source;
|
||||
return mockBranches.value;
|
||||
},
|
||||
}));
|
||||
|
||||
import { RepoChipsRow } from "./task-create-dialog-repo-chips";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const REPO_FRONT_ID = "repo-front";
|
||||
const REPO_BACK_ID = "repo-back";
|
||||
|
||||
function makeRepo(id: string, name: string): Repository {
|
||||
return {
|
||||
id,
|
||||
workspace_id: "ws-1",
|
||||
name,
|
||||
source_type: "local",
|
||||
local_path: `/repos/${name}`,
|
||||
default_branch: "main",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as Repository;
|
||||
}
|
||||
|
||||
function row(overrides: Partial<TaskRepoRow> = {}): TaskRepoRow {
|
||||
return { key: `row-${Math.random()}`, branch: "", ...overrides };
|
||||
}
|
||||
|
||||
function makeFs(overrides: Partial<DialogFormState>): DialogFormState {
|
||||
// Only the fields RepoChipsRow actually reads/sets need to be real.
|
||||
return {
|
||||
repositories: [] as TaskRepoRow[],
|
||||
useGitHubUrl: false,
|
||||
discoveredRepositories: [],
|
||||
addRepository: vi.fn(),
|
||||
removeRepository: vi.fn(),
|
||||
updateRepository: vi.fn(),
|
||||
setRepositories: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as DialogFormState;
|
||||
}
|
||||
|
||||
const NOOP = (_key: string, _value: string) => undefined;
|
||||
|
||||
describe("RepoChipsRow", () => {
|
||||
it("renders one chip per row plus an Add button", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId("repo-chip")).toHaveLength(1);
|
||||
expect(screen.getByTestId("add-repository")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders one chip per row across multiple repos", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({
|
||||
repositories: [
|
||||
row({ key: "r0", repositoryId: REPO_FRONT_ID }),
|
||||
row({ key: "r1", repositoryId: REPO_BACK_ID, branch: "main" }),
|
||||
],
|
||||
})}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId("repo-chip")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders the GitHub URL input in URL mode (chips suppressed)", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({ useGitHubUrl: true, githubUrl: "" })}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
onToggleGitHubUrl={() => undefined}
|
||||
onGitHubUrlChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("repo-chips-row")).toBeTruthy();
|
||||
expect(screen.queryAllByTestId("repo-chip")).toHaveLength(0);
|
||||
expect(screen.getByTestId("github-url-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the chip row when the task is already started", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("repo-chips-row")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables Add when no more repositories are available", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({
|
||||
repositories: [
|
||||
row({ key: "r0", repositoryId: REPO_FRONT_ID }),
|
||||
row({ key: "r1", repositoryId: REPO_BACK_ID }),
|
||||
],
|
||||
})}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByTestId("add-repository") as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls fs.addRepository when the + button is clicked", () => {
|
||||
const fs = makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] });
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={fs}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("add-repository"));
|
||||
expect(fs.addRepository).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("removing a chip calls fs.removeRepository(key)", () => {
|
||||
const fs = makeFs({
|
||||
repositories: [
|
||||
row({ key: "r0", repositoryId: REPO_FRONT_ID, branch: "main" }),
|
||||
row({ key: "r1", repositoryId: REPO_BACK_ID, branch: "develop" }),
|
||||
],
|
||||
});
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={fs}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend"), makeRepo(REPO_BACK_ID, "backend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByTestId("remove-repo-chip")[0]);
|
||||
expect(fs.removeRepository).toHaveBeenCalledWith("r0");
|
||||
});
|
||||
|
||||
// Regression: discovered (on-machine) repos must surface in the picker
|
||||
// dropdown alongside workspace repos. A previous rewrite passed [] for
|
||||
// discovered repos and lost them.
|
||||
it("includes discovered (on-machine) repos in the picker dropdown", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({
|
||||
repositories: [row({ key: "r0" })],
|
||||
discoveredRepositories: [
|
||||
{ path: "/home/me/projects/local-project", name: "local-project" },
|
||||
// Same path as a workspace repo — should NOT appear (dedup by path).
|
||||
{ path: "/repos/frontend", name: "frontend-dup" },
|
||||
] as unknown as DialogFormState["discoveredRepositories"],
|
||||
})}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("repo-chip-trigger"));
|
||||
expect(screen.getByText("frontend")).toBeTruthy();
|
||||
expect(screen.getByText("projects/local-project")).toBeTruthy();
|
||||
expect(screen.queryByText("frontend-dup")).toBeNull();
|
||||
});
|
||||
|
||||
// Regression: picking a discovered repo passes its path as the value;
|
||||
// onRowRepositoryChange must receive it (the handler then resolves to a
|
||||
// workspace id or local path). Previously the chip wrote the path into
|
||||
// a workspace `repository_id`, causing FK failures on submit.
|
||||
it("calls onRowRepositoryChange with the discovered path when picked", () => {
|
||||
const onRowRepositoryChange = vi.fn();
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({
|
||||
repositories: [row({ key: "r0" })],
|
||||
discoveredRepositories: [
|
||||
{ path: "/home/me/projects/local-project", name: "local-project" },
|
||||
] as unknown as DialogFormState["discoveredRepositories"],
|
||||
})}
|
||||
repositories={[]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={onRowRepositoryChange}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("repo-chip-trigger"));
|
||||
fireEvent.click(screen.getByText("projects/local-project"));
|
||||
expect(onRowRepositoryChange).toHaveBeenCalledWith(
|
||||
"r0",
|
||||
"/home/me/projects/local-project",
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: discovered (path-keyed) rows used to call the branch loader
|
||||
// with no path source, so their branch picker stayed empty. The chip must
|
||||
// build a `kind: "path"` source for discovered rows so the unified hook
|
||||
// hits the path-based query param instead of trying an id lookup.
|
||||
it("discovered rows build a path-source for the branch loader", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({
|
||||
repositories: [row({ key: "r0", localPath: "/home/me/projects/proj" })],
|
||||
})}
|
||||
repositories={[]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
expect(lastBranchSource.value).toEqual({
|
||||
kind: "path",
|
||||
workspaceId: "ws-1",
|
||||
path: "/home/me/projects/proj",
|
||||
});
|
||||
});
|
||||
|
||||
it("workspace rows build an id-source for the branch loader", () => {
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
expect(lastBranchSource.value).toEqual({
|
||||
kind: "id",
|
||||
workspaceId: "ws-1",
|
||||
repositoryId: REPO_FRONT_ID,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: remote branches must keep their "origin/" prefix so users
|
||||
// can distinguish a local "main" from "origin/main". A prior rewrite
|
||||
// dropped the prefix, producing two indistinguishable rows.
|
||||
it("remote branches keep their origin/ prefix and don't collide with local names", () => {
|
||||
mockBranches.value = {
|
||||
isLoading: false,
|
||||
branches: [
|
||||
{ name: "main", type: "local" },
|
||||
{ name: "main", type: "remote", remote: "origin" },
|
||||
] as unknown as Branch[],
|
||||
};
|
||||
render(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] })}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("branch-chip-trigger"));
|
||||
expect(screen.getByText("main")).toBeTruthy();
|
||||
expect(screen.getByText("origin/main")).toBeTruthy();
|
||||
// Reset for sibling tests.
|
||||
mockBranches.value = { branches: [], isLoading: false };
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { IconPlus, IconX, IconCode, IconGitBranch } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@kandev/ui/command";
|
||||
import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches";
|
||||
import type { Branch, LocalRepository, Repository } from "@/lib/types/http";
|
||||
import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types";
|
||||
|
||||
/**
|
||||
* Chip row for the task-create dialog. Renders one chip per row in
|
||||
* `fs.repositories`, plus a trailing "+" to add another. Each chip is two
|
||||
* pills (repo, branch) and a remove (×). All chips are equivalent — there
|
||||
* is no "primary" — and any row can hold either a workspace repo or a
|
||||
* discovered on-machine path.
|
||||
*
|
||||
* In GitHub URL mode the chips are replaced by an inline URL input pill;
|
||||
* the trailing toggle flips between the two modes.
|
||||
*/
|
||||
type RepoChipsRowProps = {
|
||||
fs: DialogFormState;
|
||||
repositories: Repository[];
|
||||
isTaskStarted: boolean;
|
||||
/** Required for loading branches on discovered (path-keyed) rows. */
|
||||
workspaceId: string | null;
|
||||
/**
|
||||
* Per-row repo change handler. Resolves the picked value into either a
|
||||
* workspace `repositoryId` or a discovered `localPath` and writes that
|
||||
* into the row. Comes from useDialogHandlers so the resolution logic
|
||||
* stays in one place.
|
||||
*/
|
||||
onRowRepositoryChange: (key: string, value: string) => void;
|
||||
onRowBranchChange: (key: string, value: string) => void;
|
||||
/** GitHub URL flow lives alongside the chips so users can switch in place. */
|
||||
onToggleGitHubUrl?: () => void;
|
||||
onGitHubUrlChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function RepoChipsRow({
|
||||
fs,
|
||||
repositories,
|
||||
isTaskStarted,
|
||||
workspaceId,
|
||||
onRowRepositoryChange,
|
||||
onRowBranchChange,
|
||||
onToggleGitHubUrl,
|
||||
onGitHubUrlChange,
|
||||
}: RepoChipsRowProps) {
|
||||
// No early returns above hooks. URL mode and started-state checks happen below.
|
||||
const usedIds = useMemo(() => collectUsedRepoIds(fs.repositories), [fs.repositories]);
|
||||
if (isTaskStarted) return null;
|
||||
|
||||
const remainingCount = repositories.filter((r) => !usedIds.has(r.id)).length;
|
||||
// Add stays enabled when no workspace repos are left, since users can also
|
||||
// pick from discovered on-machine paths.
|
||||
const hasDiscovered = fs.discoveredRepositories.length > 0;
|
||||
const canAddMore = remainingCount > 0 || hasDiscovered;
|
||||
const addHint = canAddMore ? undefined : "All workspace repositories are already added";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" data-testid="repo-chips-row">
|
||||
{fs.useGitHubUrl ? (
|
||||
<GitHubUrlPill
|
||||
githubUrl={fs.githubUrl}
|
||||
githubUrlError={fs.githubUrlError}
|
||||
onGitHubUrlChange={onGitHubUrlChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{fs.repositories.map((row) => (
|
||||
<RepoChip
|
||||
key={row.key}
|
||||
row={row}
|
||||
workspaceId={workspaceId}
|
||||
repositories={repositories}
|
||||
discoveredRepositories={fs.discoveredRepositories}
|
||||
excludedRepoIds={collectUsedRepoIds(fs.repositories, row.key)}
|
||||
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
|
||||
onBranchChange={(value) => onRowBranchChange(row.key, value)}
|
||||
onRemove={() => fs.removeRepository(row.key)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={fs.addRepository}
|
||||
disabled={!canAddMore}
|
||||
title={addHint}
|
||||
aria-label="Add repository"
|
||||
data-testid="add-repository"
|
||||
className={cn(
|
||||
"h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground",
|
||||
canAddMore
|
||||
? "hover:bg-muted hover:text-foreground cursor-pointer"
|
||||
: "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onToggleGitHubUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGitHubUrl}
|
||||
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
data-testid="toggle-github-url"
|
||||
>
|
||||
{fs.useGitHubUrl ? "use a workspace repository" : "or paste a GitHub URL"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the set of repo identifiers (workspace id or path) currently in use. */
|
||||
function collectUsedRepoIds(rows: TaskRepoRow[], exceptKey?: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const r of rows) {
|
||||
if (r.key === exceptKey) continue;
|
||||
if (r.repositoryId) ids.add(r.repositoryId);
|
||||
if (r.localPath) ids.add(r.localPath);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
type RepoChipProps = {
|
||||
row: TaskRepoRow;
|
||||
/** Required for path-based branch loading on discovered rows. */
|
||||
workspaceId: string | null;
|
||||
repositories: Repository[];
|
||||
discoveredRepositories: LocalRepository[];
|
||||
/** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */
|
||||
excludedRepoIds: Set<string>;
|
||||
onRepositoryChange: (value: string) => void;
|
||||
onBranchChange: (value: string) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function RepoChip({
|
||||
row,
|
||||
workspaceId,
|
||||
repositories,
|
||||
discoveredRepositories,
|
||||
excludedRepoIds,
|
||||
onRepositoryChange,
|
||||
onBranchChange,
|
||||
onRemove,
|
||||
}: RepoChipProps) {
|
||||
const filteredRepos = useMemo(
|
||||
() =>
|
||||
repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId),
|
||||
[repositories, excludedRepoIds, row.repositoryId],
|
||||
);
|
||||
// Discovered (on-machine) repos not yet imported into the workspace. Drop
|
||||
// ones whose path is already a workspace repo so the dropdown doesn't show
|
||||
// the same folder twice.
|
||||
const filteredDiscovered = useMemo(() => {
|
||||
const workspaceRepoPaths = new Set(
|
||||
filteredRepos
|
||||
.map((r) => r.local_path)
|
||||
.filter(Boolean)
|
||||
.map((path: string) => normalizeRepoPath(path)),
|
||||
);
|
||||
return discoveredRepositories.filter(
|
||||
(r) =>
|
||||
!workspaceRepoPaths.has(normalizeRepoPath(r.path)) &&
|
||||
(!excludedRepoIds.has(r.path) || r.path === row.localPath),
|
||||
);
|
||||
}, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]);
|
||||
|
||||
// One hook for both row sources (workspace repo by id OR on-machine path).
|
||||
// Backend resolves either to an absolute path; the Zustand cache keys
|
||||
// path-based entries with a synthetic key so workspace + discovered share
|
||||
// one slice (and the same fetch dedupe).
|
||||
const branchSource = useMemo<BranchSource | null>(() => {
|
||||
if (!workspaceId) return null;
|
||||
if (row.repositoryId) {
|
||||
return { kind: "id", workspaceId, repositoryId: row.repositoryId };
|
||||
}
|
||||
if (row.localPath) {
|
||||
return { kind: "path", workspaceId, path: row.localPath };
|
||||
}
|
||||
return null;
|
||||
}, [workspaceId, row.repositoryId, row.localPath]);
|
||||
const { branches, isLoading: branchesLoading } = useBranches(branchSource, !!branchSource);
|
||||
|
||||
const repoOptions: PillOption[] = useMemo(() => {
|
||||
return [
|
||||
...filteredRepos.map((r) => ({ value: r.id, label: r.name })),
|
||||
...filteredDiscovered.map((r) => ({
|
||||
value: r.path,
|
||||
label: shortRepoPath(r.path),
|
||||
keywords: [r.path],
|
||||
})),
|
||||
];
|
||||
}, [filteredRepos, filteredDiscovered]);
|
||||
const branchOptions: PillOption[] = useMemo(() => branches.map(branchToOption), [branches]);
|
||||
|
||||
const repoLabel =
|
||||
repositories.find((r) => r.id === row.repositoryId)?.name ??
|
||||
discoveredRepositories.find((r) => r.path === row.localPath)?.path?.split("/").pop() ??
|
||||
"";
|
||||
const hasRepo = !!(row.repositoryId || row.localPath);
|
||||
const branchPlaceholder = !hasRepo
|
||||
? "branch"
|
||||
: branchesLoading
|
||||
? "loading…"
|
||||
: branchOptions.length === 0
|
||||
? "no branches"
|
||||
: "branch";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
data-testid="repo-chip"
|
||||
data-repository-id={row.repositoryId || row.localPath || ""}
|
||||
>
|
||||
<Pill
|
||||
icon={<IconCode className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
value={repoLabel}
|
||||
placeholder="repository"
|
||||
options={repoOptions}
|
||||
onSelect={onRepositoryChange}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage="No repositories"
|
||||
testId="repo-chip-trigger"
|
||||
/>
|
||||
<Pill
|
||||
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
value={row.branch}
|
||||
placeholder={branchPlaceholder}
|
||||
options={branchOptions}
|
||||
onSelect={onBranchChange}
|
||||
disabled={!hasRepo || branchesLoading || branchOptions.length === 0}
|
||||
searchPlaceholder="Search branches..."
|
||||
emptyMessage="No branches"
|
||||
testId="branch-chip-trigger"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove repository"
|
||||
className="h-7 w-6 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-muted cursor-pointer"
|
||||
data-testid="remove-repo-chip"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type PillOption = { value: string; label: string; keywords?: string[] };
|
||||
|
||||
function normalizeRepoPath(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function shortRepoPath(path: string): string {
|
||||
// Show the trailing folder name with one parent for context (e.g. "myorg/myrepo").
|
||||
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
if (parts.length <= 1) return path;
|
||||
return parts.slice(-2).join("/");
|
||||
}
|
||||
|
||||
function branchToOption(b: Branch): PillOption {
|
||||
// Remote branches keep their "origin/" prefix so they're distinguishable
|
||||
// from local branches with the same short name (e.g. "main" vs "origin/main").
|
||||
// Without the prefix, the dropdown shows two indistinguishable rows.
|
||||
const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name;
|
||||
return { value: display, label: display };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill trigger that opens a popover with a search list. Auto-widths
|
||||
* to its content (no `w-full`, no chevron) so multiple pills can sit on one
|
||||
* line without overlapping or stretching to fill the row.
|
||||
*/
|
||||
function Pill({
|
||||
icon,
|
||||
value,
|
||||
placeholder,
|
||||
options,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
testId,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
options: PillOption[];
|
||||
onSelect: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
searchPlaceholder: string;
|
||||
emptyMessage: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasValue = !!value;
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs",
|
||||
"border border-border/60 bg-muted/30",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:bg-muted hover:border-border cursor-pointer",
|
||||
!hasValue && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate max-w-[160px]">{value || placeholder}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start" portal={false}>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label, ...(option.keywords ?? [])]}
|
||||
onSelect={() => {
|
||||
onSelect(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubUrlPill({
|
||||
githubUrl,
|
||||
githubUrlError,
|
||||
onGitHubUrlChange,
|
||||
}: {
|
||||
githubUrl: string;
|
||||
githubUrlError: string | null;
|
||||
onGitHubUrlChange?: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative inline-flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(e) => onGitHubUrlChange?.(e.target.value)}
|
||||
placeholder="github.com/owner/repo"
|
||||
data-testid="github-url-input"
|
||||
aria-label="GitHub repository URL"
|
||||
aria-invalid={!!githubUrlError}
|
||||
aria-describedby={githubUrlError ? "github-url-error" : undefined}
|
||||
className={cn(
|
||||
"h-7 rounded-md px-2.5 text-xs bg-muted/30 border border-border/60",
|
||||
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
|
||||
githubUrlError && "border-destructive text-destructive",
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
{githubUrlError && (
|
||||
<div
|
||||
id="github-url-error"
|
||||
role="alert"
|
||||
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
|
||||
data-testid="github-url-error"
|
||||
>
|
||||
{githubUrlError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
|
||||
|
||||
/**
|
||||
* Manages the unified `repositories` list for task creation. Every chip
|
||||
* (one or many) is an entry; there is no "primary" or "extras" split.
|
||||
*
|
||||
* `nextKey` increments to give each row a stable client-side key without
|
||||
* relying on array indices (which would shift on removal and break
|
||||
* uncontrolled inputs).
|
||||
*/
|
||||
export function useRepositoriesState() {
|
||||
const [repositories, setRepositories] = useState<TaskRepoRow[]>([]);
|
||||
const nextKeyRef = useRef(0);
|
||||
|
||||
const addRepository = useCallback(() => {
|
||||
nextKeyRef.current += 1;
|
||||
const key = `row-${nextKeyRef.current}`;
|
||||
setRepositories((rows) => [...rows, { key, branch: "" }]);
|
||||
}, []);
|
||||
|
||||
const removeRepository = useCallback((key: string) => {
|
||||
setRepositories((rows) => rows.filter((r) => r.key !== key));
|
||||
}, []);
|
||||
|
||||
const updateRepository = useCallback((key: string, patch: Partial<TaskRepoRow>) => {
|
||||
setRepositories((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
}, []);
|
||||
|
||||
return { repositories, setRepositories, addRepository, removeRepository, updateRepository };
|
||||
}
|
||||
@@ -79,6 +79,7 @@ type BranchSelectorProps = {
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyMessage: string;
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export const BranchSelector = memo(function BranchSelector({
|
||||
@@ -89,6 +90,7 @@ export const BranchSelector = memo(function BranchSelector({
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
triggerClassName,
|
||||
}: BranchSelectorProps) {
|
||||
return (
|
||||
<Combobox
|
||||
@@ -101,6 +103,7 @@ export const BranchSelector = memo(function BranchSelector({
|
||||
disabled={disabled}
|
||||
dropdownLabel="Base Branch"
|
||||
className={disabled ? undefined : CURSOR_POINTER_CLASS}
|
||||
triggerClassName={triggerClassName}
|
||||
testId="branch-selector"
|
||||
/>
|
||||
);
|
||||
@@ -233,10 +236,9 @@ export const InlineTaskName = memo(function InlineTaskName({
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="task-name"
|
||||
placeholder="Task name"
|
||||
data-testid="task-title-input"
|
||||
size={Math.max(value.length, 9)}
|
||||
className="bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-muted focus:bg-muted transition-colors"
|
||||
className="w-full bg-transparent border-none outline-none focus:ring-0 text-sm font-medium rounded-md px-2 py-1 -mx-2 hover:bg-muted/30 focus:bg-muted/30 transition-colors placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
||||
import { useRepositoryBranches } from "@/hooks/domains/workspace/use-repository-branches";
|
||||
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
|
||||
import {
|
||||
useRepositoryOptions,
|
||||
@@ -27,8 +26,9 @@ import type {
|
||||
DialogFormState,
|
||||
DialogComputedValues,
|
||||
DialogComputedArgs,
|
||||
TaskRepoRow,
|
||||
} from "@/components/task-create-dialog-types";
|
||||
import { useExtraRepositoriesState } from "@/components/task-create-dialog-extra-repos-state";
|
||||
import { useRepositoriesState } from "@/components/task-create-dialog-repositories-state";
|
||||
import {
|
||||
computePassthroughProfile,
|
||||
computeEffectiveStepId,
|
||||
@@ -44,17 +44,14 @@ type FormResetters = {
|
||||
setTaskName: (v: string) => void;
|
||||
setHasTitle: (v: boolean) => void;
|
||||
setHasDescription: (v: boolean) => void;
|
||||
setRepositoryId: (v: string) => void;
|
||||
setBranch: (v: string) => void;
|
||||
setRepositories: (v: TaskRepoRow[]) => void;
|
||||
setGitHubBranch: (v: string) => void;
|
||||
setAgentProfileId: (v: string) => void;
|
||||
setExecutorId: (v: string) => void;
|
||||
setExecutorProfileId: (v: string) => void;
|
||||
setSelectedWorkflowId: (v: string | null) => void;
|
||||
setFetchedSteps: (v: StepType[] | null) => void;
|
||||
setDiscoveredRepositories: (v: LocalRepository[]) => void;
|
||||
setDiscoveredRepoPath: (v: string) => void;
|
||||
setSelectedLocalRepo: (v: LocalRepository | null) => void;
|
||||
setLocalBranches: (v: Branch[]) => void;
|
||||
setDiscoverReposLoaded: (v: boolean) => void;
|
||||
setUseGitHubUrl: (v: boolean) => void;
|
||||
setGitHubUrl: (v: string) => void;
|
||||
@@ -145,8 +142,21 @@ function resetTaskForm(
|
||||
resetters.setTaskName(name);
|
||||
resetters.setHasTitle(name.trim().length > 0);
|
||||
resetters.setHasDescription(description.trim().length > 0);
|
||||
resetters.setRepositoryId(initialValues?.repositoryId ?? "");
|
||||
resetters.setBranch(initialValues?.branch ?? "");
|
||||
// Seed the unified repos list from initialValues. A repo + branch pre-fill
|
||||
// becomes a single row; nothing seeds an empty list (the auto-select
|
||||
// effect later picks the user's last-used repo or the first workspace one).
|
||||
if (initialValues?.repositoryId) {
|
||||
resetters.setRepositories([
|
||||
{
|
||||
key: "row-0",
|
||||
repositoryId: initialValues.repositoryId,
|
||||
branch: initialValues.branch ?? "",
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
resetters.setRepositories([]);
|
||||
}
|
||||
resetters.setGitHubBranch(initialValues?.branch ?? "");
|
||||
resetters.setAgentProfileId("");
|
||||
resetters.setExecutorId("");
|
||||
resetters.setExecutorProfileId("");
|
||||
@@ -158,9 +168,6 @@ function resetTaskForm(
|
||||
function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInitialValues) {
|
||||
const ghUrl = iv?.githubUrl ?? "";
|
||||
resetters.setDiscoveredRepositories([]);
|
||||
resetters.setDiscoveredRepoPath("");
|
||||
resetters.setSelectedLocalRepo(null);
|
||||
resetters.setLocalBranches([]);
|
||||
resetters.setDiscoverReposLoaded(false);
|
||||
resetters.setUseGitHubUrl(Boolean(ghUrl));
|
||||
resetters.setGitHubUrl(ghUrl);
|
||||
@@ -266,8 +273,9 @@ function useFormStateValues(
|
||||
const [draftDescription, setDraftDescription] = useState("");
|
||||
|
||||
const descriptionInputRef = useRef<TaskFormInputsHandle | null>(null);
|
||||
const [repositoryId, setRepositoryId] = useState(initialValues?.repositoryId ?? "");
|
||||
const [branch, setBranch] = useState(initialValues?.branch ?? "");
|
||||
// GitHub URL flow has its own branch field (the per-repo branch lives on
|
||||
// each row in `repositories`). Seed from initialValues for the URL flow only.
|
||||
const [githubBranch, setGitHubBranch] = useState(initialValues?.branch ?? "");
|
||||
const [agentProfileId, setAgentProfileId] = useState("");
|
||||
const [executorId, setExecutorId] = useState("");
|
||||
const [executorProfileId, setExecutorProfileId] = useState("");
|
||||
@@ -285,10 +293,8 @@ function useFormStateValues(
|
||||
draftDescription,
|
||||
setDraftDescription,
|
||||
descriptionInputRef,
|
||||
repositoryId,
|
||||
setRepositoryId,
|
||||
branch,
|
||||
setBranch,
|
||||
githubBranch,
|
||||
setGitHubBranch,
|
||||
agentProfileId,
|
||||
setAgentProfileId,
|
||||
executorId,
|
||||
@@ -311,26 +317,17 @@ function useFormStateValues(
|
||||
};
|
||||
}
|
||||
|
||||
/** Repository discovery state */
|
||||
/** Repository discovery state — just the discovered list. The previous
|
||||
* per-form `selectedLocalRepo` / `discoveredRepoPath` / `localBranches`
|
||||
* primary-only fields are gone; discovered repos now live as ordinary rows
|
||||
* in `fs.repositories` with `localPath` set. */
|
||||
function useDiscoveryState() {
|
||||
const [discoveredRepositories, setDiscoveredRepositories] = useState<LocalRepository[]>([]);
|
||||
const [discoveredRepoPath, setDiscoveredRepoPath] = useState("");
|
||||
const [selectedLocalRepo, setSelectedLocalRepo] = useState<LocalRepository | null>(null);
|
||||
const [localBranches, setLocalBranches] = useState<Branch[]>([]);
|
||||
const [localBranchesLoading, setLocalBranchesLoading] = useState(false);
|
||||
const [discoverReposLoading, setDiscoverReposLoading] = useState(false);
|
||||
const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false);
|
||||
return {
|
||||
discoveredRepositories,
|
||||
setDiscoveredRepositories,
|
||||
discoveredRepoPath,
|
||||
setDiscoveredRepoPath,
|
||||
selectedLocalRepo,
|
||||
setSelectedLocalRepo,
|
||||
localBranches,
|
||||
setLocalBranches,
|
||||
localBranchesLoading,
|
||||
setLocalBranchesLoading,
|
||||
discoverReposLoading,
|
||||
setDiscoverReposLoading,
|
||||
discoverReposLoaded,
|
||||
@@ -348,7 +345,7 @@ export function useDialogFormState(
|
||||
const discovery = useDiscoveryState();
|
||||
const ghUrl = useGitHubUrlState();
|
||||
const wfAgent = useWorkflowAgentProfileState();
|
||||
const extraRepos = useExtraRepositoriesState();
|
||||
const repos = useRepositoriesState();
|
||||
|
||||
useFormResetEffects({
|
||||
open,
|
||||
@@ -363,17 +360,14 @@ export function useDialogFormState(
|
||||
setTaskName: form.setTaskName,
|
||||
setHasTitle: form.setHasTitle,
|
||||
setHasDescription: form.setHasDescription,
|
||||
setRepositoryId: form.setRepositoryId,
|
||||
setBranch: form.setBranch,
|
||||
setRepositories: repos.setRepositories,
|
||||
setGitHubBranch: form.setGitHubBranch,
|
||||
setAgentProfileId: form.setAgentProfileId,
|
||||
setExecutorId: form.setExecutorId,
|
||||
setExecutorProfileId: form.setExecutorProfileId,
|
||||
setSelectedWorkflowId: form.setSelectedWorkflowId,
|
||||
setFetchedSteps: form.setFetchedSteps,
|
||||
setDiscoveredRepositories: discovery.setDiscoveredRepositories,
|
||||
setDiscoveredRepoPath: discovery.setDiscoveredRepoPath,
|
||||
setSelectedLocalRepo: discovery.setSelectedLocalRepo,
|
||||
setLocalBranches: discovery.setLocalBranches,
|
||||
setDiscoverReposLoaded: discovery.setDiscoverReposLoaded,
|
||||
setUseGitHubUrl: ghUrl.setUseGitHubUrl,
|
||||
setGitHubUrl: ghUrl.setGitHubUrl,
|
||||
@@ -391,13 +385,7 @@ export function useDialogFormState(
|
||||
form.descriptionInputRef,
|
||||
);
|
||||
|
||||
// Reset extra repositories whenever the dialog re-opens with a fresh form.
|
||||
useEffect(() => {
|
||||
if (!open) extraRepos.resetExtraRepositories();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...extraRepos, clearDraft };
|
||||
return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...repos, clearDraft };
|
||||
}
|
||||
|
||||
export type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
@@ -417,7 +405,6 @@ export function useDialogComputed({
|
||||
workspaceId,
|
||||
workflowId,
|
||||
defaultStepId,
|
||||
branches,
|
||||
settingsData,
|
||||
agentProfiles,
|
||||
workspaces,
|
||||
@@ -450,15 +437,17 @@ export function useDialogComputed({
|
||||
const workspaceDefaults = workspaceId
|
||||
? workspaces.find((ws: Workspace) => ws.id === workspaceId)
|
||||
: null;
|
||||
// The form has a repo selection when either: (a) any chip in the unified
|
||||
// list has a repo set, or (b) URL mode has a non-empty URL. The chip row
|
||||
// takes care of branch state per row; there is no global branch.
|
||||
const hasRepositorySelection = Boolean(
|
||||
fs.repositoryId || fs.selectedLocalRepo || (fs.useGitHubUrl && fs.githubUrl.trim()),
|
||||
fs.repositories.some((r) => r.repositoryId || r.localPath) ||
|
||||
(fs.useGitHubUrl && fs.githubUrl.trim()),
|
||||
);
|
||||
const effectiveBranches = (() => {
|
||||
if (fs.useGitHubUrl) return fs.githubBranches;
|
||||
if (fs.repositoryId) return branches;
|
||||
return fs.localBranches;
|
||||
})();
|
||||
const branchOptions = useBranchOptions(effectiveBranches);
|
||||
// Branch options are only used by the URL-mode flow now (the chip's branch
|
||||
// pill loads branches per-repo). Keep the computed value but always feed it
|
||||
// the URL branches when in URL mode.
|
||||
const branchOptions = useBranchOptions(fs.useGitHubUrl ? fs.githubBranches : []);
|
||||
const agentProfileOptions = useAgentProfileOptions(agentProfiles);
|
||||
const allExecutorProfiles = useMemo<ExecutorProfile[]>(() => {
|
||||
return executors.flatMap((executor) =>
|
||||
@@ -528,17 +517,16 @@ export function useTaskCreateDialogData(
|
||||
|
||||
useSettingsData(open);
|
||||
const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open);
|
||||
const { branches, isLoading: branchesLoading } = useRepositoryBranches(
|
||||
fs.repositoryId || null,
|
||||
Boolean(open && fs.repositoryId),
|
||||
);
|
||||
// Per-repo branch loading lives in each chip now (RepoChipsRow). No
|
||||
// global branch query is needed here — the chip uses useRepositoryBranches
|
||||
// for its own row, and the store dedupes by repositoryId.
|
||||
const branchesLoading = false;
|
||||
const computed = useDialogComputed({
|
||||
fs,
|
||||
open,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
defaultStepId,
|
||||
branches,
|
||||
settingsData,
|
||||
agentProfiles,
|
||||
workspaces,
|
||||
@@ -554,7 +542,6 @@ export function useTaskCreateDialogData(
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
branches,
|
||||
branchesLoading,
|
||||
computed,
|
||||
};
|
||||
|
||||
@@ -31,13 +31,12 @@ export function useTaskSubmitHandlers({
|
||||
workflowId,
|
||||
effectiveWorkflowId,
|
||||
effectiveDefaultStepId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
discoveredRepositories,
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
githubPrHeadBranch,
|
||||
branch,
|
||||
extraRepositories,
|
||||
githubBranch,
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
@@ -53,8 +52,8 @@ export function useTaskSubmitHandlers({
|
||||
setHasTitle,
|
||||
setHasDescription,
|
||||
setTaskName,
|
||||
setRepositoryId,
|
||||
setBranch,
|
||||
setRepositories,
|
||||
setGitHubBranch,
|
||||
setAgentProfileId,
|
||||
setExecutorId,
|
||||
setSelectedWorkflowId,
|
||||
@@ -70,8 +69,8 @@ export function useTaskSubmitHandlers({
|
||||
setHasTitle(false);
|
||||
setHasDescription(false);
|
||||
setTaskName("");
|
||||
setRepositoryId("");
|
||||
setBranch("");
|
||||
setRepositories([]);
|
||||
setGitHubBranch("");
|
||||
setAgentProfileId("");
|
||||
setExecutorId("");
|
||||
setSelectedWorkflowId(workflowId);
|
||||
@@ -82,8 +81,8 @@ export function useTaskSubmitHandlers({
|
||||
setHasTitle,
|
||||
setHasDescription,
|
||||
setTaskName,
|
||||
setRepositoryId,
|
||||
setBranch,
|
||||
setRepositories,
|
||||
setGitHubBranch,
|
||||
setAgentProfileId,
|
||||
setExecutorId,
|
||||
setSelectedWorkflowId,
|
||||
@@ -95,23 +94,18 @@ export function useTaskSubmitHandlers({
|
||||
buildRepositoriesPayload({
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
branch,
|
||||
githubBranch,
|
||||
githubPrHeadBranch,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
extraRepositories: extraRepositories.map((row) => ({
|
||||
repositoryId: row.repositoryId,
|
||||
branch: row.branch,
|
||||
})),
|
||||
repositories,
|
||||
discoveredRepositories,
|
||||
}),
|
||||
[
|
||||
useGitHubUrl,
|
||||
repositoryId,
|
||||
branch,
|
||||
githubUrl,
|
||||
githubBranch,
|
||||
githubPrHeadBranch,
|
||||
selectedLocalRepo,
|
||||
extraRepositories,
|
||||
repositories,
|
||||
discoveredRepositories,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -403,8 +397,7 @@ export function useTaskSubmitHandlers({
|
||||
trimmedTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
})
|
||||
@@ -433,8 +426,7 @@ export function useTaskSubmitHandlers({
|
||||
taskName,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
getRepositoriesPayload,
|
||||
@@ -454,8 +446,7 @@ export function useTaskSubmitHandlers({
|
||||
trimmedTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
})
|
||||
@@ -488,8 +479,7 @@ export function useTaskSubmitHandlers({
|
||||
taskName,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
isPassthroughProfile,
|
||||
@@ -509,8 +499,7 @@ export function useTaskSubmitHandlers({
|
||||
trimmedTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
})
|
||||
@@ -550,8 +539,7 @@ export function useTaskSubmitHandlers({
|
||||
taskName,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
effectiveDefaultStepId,
|
||||
|
||||
@@ -16,16 +16,22 @@ import type {
|
||||
import type { useToast } from "@/components/toast-provider";
|
||||
|
||||
/**
|
||||
* One extra repository row in the multi-repo task create form. The form
|
||||
* tracks these in addition to the primary (repositoryId/branch) so the
|
||||
* existing single-repo controls keep working for the common case.
|
||||
* One repository row in the task-create form. The form tracks every repo
|
||||
* (whether one or many) as an entry in `repositories[]`. There is no
|
||||
* "primary" — the backend treats them all equally and uses array order
|
||||
* for position. Removing the last row clears the form's repo selection.
|
||||
*
|
||||
* Exactly one of `repositoryId` (workspace repo) or `localPath` (on-machine
|
||||
* git folder discovered via the workspace's repo discovery) is set per row.
|
||||
* `key` is a stable client-side id used as React key and for
|
||||
* add/update/remove ops; it's not sent to the backend.
|
||||
*/
|
||||
export type ExtraRepositoryRow = {
|
||||
export type TaskRepoRow = {
|
||||
key: string;
|
||||
repositoryId: string;
|
||||
/** Workspace repo id, when the user picked from the workspace's repos. */
|
||||
repositoryId?: string;
|
||||
/** On-machine repo path, when the user picked from discovered repos. */
|
||||
localPath?: string;
|
||||
branch: string;
|
||||
};
|
||||
|
||||
@@ -86,7 +92,6 @@ export type DialogComputedArgs = {
|
||||
workspaceId: string | null;
|
||||
workflowId: string | null;
|
||||
defaultStepId: string | null;
|
||||
branches: Branch[];
|
||||
settingsData: { agentsLoaded: boolean; executorsLoaded: boolean };
|
||||
agentProfiles: AgentProfileOption[];
|
||||
workspaces: Workspace[];
|
||||
@@ -101,7 +106,6 @@ export type TaskCreateEffectsArgs = {
|
||||
workflowId: string | null;
|
||||
repositories: Repository[];
|
||||
repositoriesLoading: boolean;
|
||||
branches: Branch[];
|
||||
agentProfiles: AgentProfileOption[];
|
||||
executors: Executor[];
|
||||
workspaceDefaults: Workspace | null | undefined;
|
||||
@@ -131,19 +135,19 @@ export type DialogFormState = {
|
||||
/** Computed defaults for current open cycle (includes draft restoration) */
|
||||
currentDefaults: { name: string; description: string };
|
||||
descriptionInputRef: import("react").RefObject<TaskFormInputsHandle | null>;
|
||||
repositoryId: string;
|
||||
setRepositoryId: (v: string) => void;
|
||||
branch: string;
|
||||
setBranch: (v: string) => void;
|
||||
/**
|
||||
* Extra repository rows beyond the primary, for multi-repo tasks.
|
||||
* The primary repo (repositoryId/branch above) is always position 0;
|
||||
* each extra row gets the next position. Empty array = single-repo task.
|
||||
* Unified list of repos on this task. Each row carries either a workspace
|
||||
* `repositoryId` or a discovered `localPath`, plus its base `branch`. The
|
||||
* order is the position the backend sees. There is no "primary" concept.
|
||||
*/
|
||||
extraRepositories: ExtraRepositoryRow[];
|
||||
addExtraRepository: () => void;
|
||||
removeExtraRepository: (key: string) => void;
|
||||
updateExtraRepository: (key: string, patch: Partial<ExtraRepositoryRow>) => void;
|
||||
repositories: TaskRepoRow[];
|
||||
setRepositories: (v: TaskRepoRow[]) => void;
|
||||
addRepository: () => void;
|
||||
removeRepository: (key: string) => void;
|
||||
updateRepository: (key: string, patch: Partial<TaskRepoRow>) => void;
|
||||
/** GitHub URL mode: a separate flow that replaces the chip row with a URL input. */
|
||||
githubBranch: string;
|
||||
setGitHubBranch: (v: string) => void;
|
||||
agentProfileId: string;
|
||||
setAgentProfileId: (v: string) => void;
|
||||
executorId: string;
|
||||
@@ -152,14 +156,6 @@ export type DialogFormState = {
|
||||
setExecutorProfileId: (v: string) => void;
|
||||
discoveredRepositories: LocalRepository[];
|
||||
setDiscoveredRepositories: (v: LocalRepository[]) => void;
|
||||
discoveredRepoPath: string;
|
||||
setDiscoveredRepoPath: (v: string) => void;
|
||||
selectedLocalRepo: LocalRepository | null;
|
||||
setSelectedLocalRepo: (v: LocalRepository | null) => void;
|
||||
localBranches: Branch[];
|
||||
setLocalBranches: (v: Branch[]) => void;
|
||||
localBranchesLoading: boolean;
|
||||
setLocalBranchesLoading: (v: boolean) => void;
|
||||
discoverReposLoading: boolean;
|
||||
setDiscoverReposLoading: (v: boolean) => void;
|
||||
discoverReposLoaded: boolean;
|
||||
@@ -200,17 +196,15 @@ export type SubmitHandlersDeps = {
|
||||
workflowId: string | null;
|
||||
effectiveWorkflowId: string | null;
|
||||
effectiveDefaultStepId: string | null;
|
||||
repositoryId: string;
|
||||
selectedLocalRepo: LocalRepository | null;
|
||||
/** Unified repo list from the form. Empty when in GitHub URL mode. */
|
||||
repositories: TaskRepoRow[];
|
||||
/** All on-machine discovered repos — used to look up `default_branch` for `localPath` rows. */
|
||||
discoveredRepositories: LocalRepository[];
|
||||
useGitHubUrl: boolean;
|
||||
githubUrl: string;
|
||||
githubPrHeadBranch: string | null;
|
||||
branch: string;
|
||||
/**
|
||||
* Extra repository rows from the multi-repo form. Submit handlers append
|
||||
* these to the primary in the same payload position order.
|
||||
*/
|
||||
extraRepositories: ExtraRepositoryRow[];
|
||||
/** Branch for the GitHub URL flow. Per-row branches live on `repositories[i].branch`. */
|
||||
githubBranch: string;
|
||||
agentProfileId: string;
|
||||
executorId: string;
|
||||
executorProfileId: string;
|
||||
@@ -237,8 +231,8 @@ export type SubmitHandlersDeps = {
|
||||
setHasTitle: (v: boolean) => void;
|
||||
setHasDescription: (v: boolean) => void;
|
||||
setTaskName: (v: string) => void;
|
||||
setRepositoryId: (v: string) => void;
|
||||
setBranch: (v: string) => void;
|
||||
setRepositories: (v: TaskRepoRow[]) => void;
|
||||
setGitHubBranch: (v: string) => void;
|
||||
setAgentProfileId: (v: string) => void;
|
||||
setExecutorId: (v: string) => void;
|
||||
setSelectedWorkflowId: (v: string | null) => void;
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
useAgentProfileOptions,
|
||||
} from "@/components/task-create-dialog-options";
|
||||
import {
|
||||
RepositorySelector,
|
||||
AgentSelector,
|
||||
ExecutorProfileSelector,
|
||||
InlineTaskName,
|
||||
} from "@/components/task-create-dialog-selectors";
|
||||
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
|
||||
import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors";
|
||||
import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import {
|
||||
useDialogFormState,
|
||||
@@ -74,145 +74,22 @@ interface TaskCreateDialogProps {
|
||||
parentTaskId?: string;
|
||||
}
|
||||
|
||||
function getRepositoryPlaceholder(
|
||||
workspaceId: string | null,
|
||||
repositoriesLoading: boolean,
|
||||
discoverReposLoading: boolean,
|
||||
) {
|
||||
if (!workspaceId) return "Select workspace first";
|
||||
if (repositoriesLoading || discoverReposLoading) return "Loading...";
|
||||
return "Select repository";
|
||||
}
|
||||
|
||||
type DialogHeaderContentProps = {
|
||||
isCreateMode: boolean;
|
||||
isEditMode: boolean;
|
||||
isTaskStarted: boolean;
|
||||
sessionRepoName?: string;
|
||||
initialTitle?: string;
|
||||
taskName: string;
|
||||
repositoryId: string;
|
||||
discoveredRepoPath: string;
|
||||
workspaceId: string | null;
|
||||
repositoriesLoading: boolean;
|
||||
discoverReposLoading: boolean;
|
||||
headerRepositoryOptions: ReturnType<typeof useRepositoryOptions>["headerRepositoryOptions"];
|
||||
onRepositoryChange: (v: string) => void;
|
||||
onTaskNameChange: (v: string) => void;
|
||||
useGitHubUrl: boolean;
|
||||
githubUrl: string;
|
||||
githubUrlError: string | null;
|
||||
onToggleGitHubUrl: () => void;
|
||||
onGitHubUrlChange: (v: string) => void;
|
||||
};
|
||||
|
||||
function RepoSourceInput({
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
githubUrlError,
|
||||
onGitHubUrlChange,
|
||||
isTaskStarted,
|
||||
headerRepositoryOptions,
|
||||
repositoryId,
|
||||
discoveredRepoPath,
|
||||
onRepositoryChange,
|
||||
workspaceId,
|
||||
repositoriesLoading,
|
||||
discoverReposLoading,
|
||||
}: Pick<
|
||||
DialogHeaderContentProps,
|
||||
| "useGitHubUrl"
|
||||
| "githubUrl"
|
||||
| "githubUrlError"
|
||||
| "onGitHubUrlChange"
|
||||
| "isTaskStarted"
|
||||
| "headerRepositoryOptions"
|
||||
| "repositoryId"
|
||||
| "discoveredRepoPath"
|
||||
| "onRepositoryChange"
|
||||
| "workspaceId"
|
||||
| "repositoriesLoading"
|
||||
| "discoverReposLoading"
|
||||
>): React.ReactNode {
|
||||
if (useGitHubUrl) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(e) => onGitHubUrlChange(e.target.value)}
|
||||
placeholder="github.com/owner/repo"
|
||||
data-testid="github-url-input"
|
||||
aria-label="GitHub repository URL"
|
||||
aria-invalid={!!githubUrlError}
|
||||
aria-describedby={githubUrlError ? "github-url-error" : undefined}
|
||||
size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)}
|
||||
className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`}
|
||||
disabled={isTaskStarted}
|
||||
autoFocus
|
||||
/>
|
||||
{githubUrlError && (
|
||||
<div
|
||||
id="github-url-error"
|
||||
role="alert"
|
||||
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
|
||||
data-testid="github-url-error"
|
||||
>
|
||||
{githubUrlError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RepositorySelector
|
||||
options={headerRepositoryOptions}
|
||||
value={repositoryId || discoveredRepoPath}
|
||||
onValueChange={onRepositoryChange}
|
||||
placeholder={getRepositoryPlaceholder(workspaceId, repositoriesLoading, discoverReposLoading)}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage={
|
||||
repositoriesLoading || discoverReposLoading
|
||||
? "Loading repositories..."
|
||||
: "No repositories found."
|
||||
}
|
||||
disabled={isTaskStarted || !workspaceId || repositoriesLoading || discoverReposLoading}
|
||||
triggerClassName="w-auto text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeaderContent(props: DialogHeaderContentProps) {
|
||||
const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props;
|
||||
const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props;
|
||||
const { isCreateMode, isEditMode, sessionRepoName, initialTitle } = props;
|
||||
|
||||
if (isCreateMode || isEditMode) {
|
||||
// Header is intentionally minimal — the dialog itself signals "create"
|
||||
// and the task name input lives in the body below the repo chips. The
|
||||
// GitHub URL input + toggle moved into the chip row as well.
|
||||
return (
|
||||
<>
|
||||
<DialogTitle asChild>
|
||||
<div className="flex items-center gap-1 text-sm font-medium min-w-0">
|
||||
<RepoSourceInput {...props} />
|
||||
<span className="text-muted-foreground mr-2">/</span>
|
||||
<InlineTaskName
|
||||
value={taskName}
|
||||
onChange={onTaskNameChange}
|
||||
autoFocus={!isEditMode && !useGitHubUrl}
|
||||
/>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
{!isTaskStarted && (
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGitHubUrl}
|
||||
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
data-testid="toggle-github-url"
|
||||
>
|
||||
{useGitHubUrl ? "or select a repository" : "or paste a GitHub URL"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<DialogTitle className="sr-only">{isEditMode ? "Edit task" : "New task"}</DialogTitle>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -235,14 +112,13 @@ function DialogHeaderContent(props: DialogHeaderContentProps) {
|
||||
type DialogFormBodyProps = {
|
||||
isSessionMode: boolean;
|
||||
isCreateMode: boolean;
|
||||
isEditMode: boolean;
|
||||
isTaskStarted: boolean;
|
||||
isPassthroughProfile: boolean;
|
||||
initialDescription: string;
|
||||
hasDescription: boolean;
|
||||
workspaceId: string | null;
|
||||
onJiraImport?: (ticket: JiraTicket) => void;
|
||||
branchOptions: ReturnType<typeof useBranchOptions>;
|
||||
branchesLoading: boolean;
|
||||
agentProfileOptions: ReturnType<typeof useAgentProfileOptions>;
|
||||
executorProfileOptions: Array<{
|
||||
value: string;
|
||||
@@ -258,29 +134,30 @@ type DialogFormBodyProps = {
|
||||
effectiveWorkflowId: string | null;
|
||||
fs: DialogFormState;
|
||||
handleKeyDown: ReturnType<typeof useKeyboardShortcutHandler>;
|
||||
onBranchChange: (v: string) => void;
|
||||
onTaskNameChange: (v: string) => void;
|
||||
onRowRepositoryChange: (key: string, value: string) => void;
|
||||
onRowBranchChange: (key: string, value: string) => void;
|
||||
onAgentProfileChange: (v: string) => void;
|
||||
onExecutorProfileChange: (v: string) => void;
|
||||
onWorkflowChange: (v: string) => void;
|
||||
hasRepositorySelection: boolean;
|
||||
isLocalExecutor: boolean;
|
||||
onToggleGitHubUrl: () => void;
|
||||
onGitHubUrlChange: (v: string) => void;
|
||||
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
|
||||
workflowAgentLocked: boolean;
|
||||
/** Workspace repositories — used by the multi-repo extra rows. */
|
||||
/** Workspace repositories — driven into the chip row for repo + branch picks. */
|
||||
repositories: Repository[];
|
||||
};
|
||||
|
||||
function DialogFormBody({
|
||||
isSessionMode,
|
||||
isCreateMode,
|
||||
isEditMode,
|
||||
isTaskStarted,
|
||||
isPassthroughProfile,
|
||||
initialDescription,
|
||||
hasDescription,
|
||||
workspaceId,
|
||||
onJiraImport,
|
||||
branchOptions,
|
||||
branchesLoading,
|
||||
agentProfileOptions,
|
||||
executorProfileOptions,
|
||||
agentProfiles,
|
||||
@@ -292,18 +169,42 @@ function DialogFormBody({
|
||||
effectiveWorkflowId,
|
||||
fs,
|
||||
handleKeyDown,
|
||||
onBranchChange,
|
||||
onTaskNameChange,
|
||||
onRowRepositoryChange,
|
||||
onRowBranchChange,
|
||||
onAgentProfileChange,
|
||||
onExecutorProfileChange,
|
||||
onWorkflowChange,
|
||||
hasRepositorySelection,
|
||||
isLocalExecutor,
|
||||
onToggleGitHubUrl,
|
||||
onGitHubUrlChange,
|
||||
enhance,
|
||||
workflowAgentLocked,
|
||||
repositories,
|
||||
}: DialogFormBodyProps) {
|
||||
// Task name renders below the chip row in create/edit mode (per UX request);
|
||||
// session mode and started tasks keep the header-only label and skip this.
|
||||
const showTaskName = !isSessionMode && (isCreateMode || isEditMode) && !isTaskStarted;
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
{!isSessionMode && (
|
||||
<RepoChipsRow
|
||||
fs={fs}
|
||||
repositories={repositories}
|
||||
isTaskStarted={isTaskStarted}
|
||||
workspaceId={workspaceId}
|
||||
onRowRepositoryChange={onRowRepositoryChange}
|
||||
onRowBranchChange={onRowBranchChange}
|
||||
onToggleGitHubUrl={onToggleGitHubUrl}
|
||||
onGitHubUrlChange={onGitHubUrlChange}
|
||||
/>
|
||||
)}
|
||||
{showTaskName && (
|
||||
<InlineTaskName
|
||||
value={fs.taskName}
|
||||
onChange={onTaskNameChange}
|
||||
autoFocus={!isEditMode && !fs.useGitHubUrl}
|
||||
/>
|
||||
)}
|
||||
<DialogPromptSection
|
||||
isSessionMode={isSessionMode}
|
||||
isTaskStarted={isTaskStarted}
|
||||
@@ -319,9 +220,6 @@ function DialogFormBody({
|
||||
{!isSessionMode && (
|
||||
<CreateModeSelectors
|
||||
isTaskStarted={isTaskStarted}
|
||||
hasRepositorySelection={hasRepositorySelection}
|
||||
branchOptions={branchOptions}
|
||||
branchesLoading={branchesLoading}
|
||||
agentProfileOptions={agentProfileOptions}
|
||||
executorProfileOptions={executorProfileOptions}
|
||||
agentProfiles={agentProfiles}
|
||||
@@ -329,12 +227,9 @@ function DialogFormBody({
|
||||
executorsLoading={executorsLoading}
|
||||
isCreatingSession={isCreatingSession}
|
||||
fs={fs}
|
||||
onBranchChange={onBranchChange}
|
||||
onAgentProfileChange={onAgentProfileChange}
|
||||
onExecutorProfileChange={onExecutorProfileChange}
|
||||
isLocalExecutor={isLocalExecutor}
|
||||
workflowAgentLocked={workflowAgentLocked}
|
||||
repositories={repositories}
|
||||
/>
|
||||
)}
|
||||
<WorkflowSection
|
||||
@@ -416,8 +311,6 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
branches,
|
||||
branchesLoading,
|
||||
computed,
|
||||
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
|
||||
useTaskCreateDialogEffects(fs, {
|
||||
@@ -426,7 +319,6 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
||||
workflowId,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
branches,
|
||||
agentProfiles,
|
||||
executors,
|
||||
workspaceDefaults: computed.workspaceDefaults,
|
||||
@@ -443,13 +335,12 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
||||
workflowId,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId,
|
||||
effectiveDefaultStepId: computed.effectiveDefaultStepId,
|
||||
repositoryId: fs.repositoryId,
|
||||
selectedLocalRepo: fs.selectedLocalRepo,
|
||||
repositories: fs.repositories,
|
||||
discoveredRepositories: fs.discoveredRepositories,
|
||||
useGitHubUrl: fs.useGitHubUrl,
|
||||
githubUrl: fs.githubUrl,
|
||||
githubPrHeadBranch: fs.githubPrHeadBranch,
|
||||
branch: fs.branch,
|
||||
extraRepositories: fs.extraRepositories,
|
||||
githubBranch: fs.githubBranch,
|
||||
agentProfileId: computed.effectiveAgentProfileId,
|
||||
executorId: fs.executorId,
|
||||
executorProfileId: fs.executorProfileId,
|
||||
@@ -465,8 +356,8 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
||||
setHasTitle: fs.setHasTitle,
|
||||
setHasDescription: fs.setHasDescription,
|
||||
setTaskName: fs.setTaskName,
|
||||
setRepositoryId: fs.setRepositoryId,
|
||||
setBranch: fs.setBranch,
|
||||
setRepositories: fs.setRepositories,
|
||||
setGitHubBranch: fs.setGitHubBranch,
|
||||
setAgentProfileId: fs.setAgentProfileId,
|
||||
setExecutorId: fs.setExecutorId,
|
||||
setSelectedWorkflowId: fs.setSelectedWorkflowId,
|
||||
@@ -488,7 +379,6 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
branchesLoading,
|
||||
computed,
|
||||
handlers,
|
||||
submitHandlers,
|
||||
@@ -503,7 +393,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
const setup = useTaskCreateDialogSetup(props);
|
||||
const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup;
|
||||
const { sessionRepoName, workflows, agentProfiles, snapshots, repositories } = setup;
|
||||
const { repositoriesLoading, branchesLoading, computed, handlers, handleKeyDown } = setup;
|
||||
const { computed, handlers, handleKeyDown } = setup;
|
||||
const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers;
|
||||
const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers;
|
||||
const { handleJiraImport } = setup;
|
||||
@@ -517,37 +407,24 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
<DialogHeaderContent
|
||||
isCreateMode={isCreateMode}
|
||||
isEditMode={isEditMode}
|
||||
isTaskStarted={isTaskStarted}
|
||||
sessionRepoName={sessionRepoName}
|
||||
initialTitle={initialValues?.title}
|
||||
taskName={fs.taskName}
|
||||
repositoryId={fs.repositoryId}
|
||||
discoveredRepoPath={fs.discoveredRepoPath}
|
||||
workspaceId={workspaceId}
|
||||
repositoriesLoading={repositoriesLoading}
|
||||
discoverReposLoading={fs.discoverReposLoading}
|
||||
headerRepositoryOptions={computed.headerRepositoryOptions}
|
||||
onRepositoryChange={handlers.handleRepositoryChange}
|
||||
onTaskNameChange={handlers.handleTaskNameChange}
|
||||
useGitHubUrl={fs.useGitHubUrl}
|
||||
githubUrl={fs.githubUrl}
|
||||
githubUrlError={fs.githubUrlError}
|
||||
onToggleGitHubUrl={handlers.handleToggleGitHubUrl}
|
||||
onGitHubUrlChange={handlers.handleGitHubUrlChange}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
|
||||
<DialogFormBody
|
||||
isSessionMode={isSessionMode}
|
||||
isCreateMode={isCreateMode}
|
||||
isEditMode={isEditMode}
|
||||
isTaskStarted={isTaskStarted}
|
||||
onTaskNameChange={handlers.handleTaskNameChange}
|
||||
onRowRepositoryChange={handlers.handleRowRepositoryChange}
|
||||
onRowBranchChange={handlers.handleRowBranchChange}
|
||||
isPassthroughProfile={computed.isPassthroughProfile}
|
||||
initialDescription={fs.currentDefaults.description}
|
||||
hasDescription={fs.hasDescription}
|
||||
workspaceId={workspaceId}
|
||||
onJiraImport={handleJiraImport}
|
||||
branchOptions={computed.branchOptions}
|
||||
branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)}
|
||||
agentProfileOptions={computed.agentProfileOptions}
|
||||
executorProfileOptions={computed.executorProfileOptions}
|
||||
agentProfiles={agentProfiles}
|
||||
@@ -559,12 +436,11 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
|
||||
fs={fs}
|
||||
handleKeyDown={handleKeyDown}
|
||||
onBranchChange={handlers.handleBranchChange}
|
||||
onAgentProfileChange={handlers.handleAgentProfileChange}
|
||||
onExecutorProfileChange={handlers.handleExecutorProfileChange}
|
||||
onWorkflowChange={handlers.handleWorkflowChange}
|
||||
hasRepositorySelection={computed.hasRepositorySelection}
|
||||
isLocalExecutor={computed.isLocalExecutor}
|
||||
onToggleGitHubUrl={handlers.handleToggleGitHubUrl}
|
||||
onGitHubUrlChange={handlers.handleGitHubUrlChange}
|
||||
enhance={setup.enhance}
|
||||
workflowAgentLocked={computed.workflowAgentLocked}
|
||||
repositories={repositories}
|
||||
@@ -581,7 +457,11 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
hasTitle={fs.hasTitle}
|
||||
hasDescription={fs.hasDescription}
|
||||
hasRepositorySelection={computed.hasRepositorySelection}
|
||||
branch={fs.branch}
|
||||
hasAllBranches={
|
||||
fs.useGitHubUrl
|
||||
? !!fs.githubBranch
|
||||
: fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch)
|
||||
}
|
||||
agentProfileId={computed.effectiveAgentProfileId}
|
||||
workspaceId={workspaceId}
|
||||
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
|
||||
|
||||
@@ -32,6 +32,8 @@ type ChangedFile = {
|
||||
plus: number | undefined;
|
||||
minus: number | undefined;
|
||||
oldPath: string | undefined;
|
||||
/** Multi-repo: name of the repo this file lives in. Empty for single-repo. */
|
||||
repositoryName?: string;
|
||||
};
|
||||
|
||||
type FileRowProps = {
|
||||
@@ -40,9 +42,12 @@ type FileRowProps = {
|
||||
isSelected?: boolean;
|
||||
onSelect?: (path: string, e: React.MouseEvent) => boolean;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
// Multi-repo: handlers receive the file's repository_name so the per-file
|
||||
// staging op routes to the right git repo. Same-named files (e.g. README.md
|
||||
// in two repos) cannot be disambiguated by path alone.
|
||||
onStage: (path: string, repo?: string) => void;
|
||||
onUnstage: (path: string, repo?: string) => void;
|
||||
onDiscard: (path: string, repo?: string) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
};
|
||||
|
||||
@@ -83,6 +88,7 @@ export function FileRow({
|
||||
isPending={isPending}
|
||||
staged={file.staged}
|
||||
path={file.path}
|
||||
repo={file.repositoryName}
|
||||
onStage={onStage}
|
||||
onUnstage={onUnstage}
|
||||
/>
|
||||
@@ -94,7 +100,12 @@ export function FileRow({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileRowActions path={file.path} onDiscard={onDiscard} onEditFile={onEditFile} />
|
||||
<FileRowActions
|
||||
path={file.path}
|
||||
repo={file.repositoryName}
|
||||
onDiscard={onDiscard}
|
||||
onEditFile={onEditFile}
|
||||
/>
|
||||
<LineStat added={file.plus} removed={file.minus} />
|
||||
<FileStatusIcon status={file.status} />
|
||||
</div>
|
||||
@@ -106,14 +117,16 @@ function StageButton({
|
||||
isPending,
|
||||
staged,
|
||||
path,
|
||||
repo,
|
||||
onStage,
|
||||
onUnstage,
|
||||
}: {
|
||||
isPending: boolean;
|
||||
staged: boolean;
|
||||
path: string;
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
repo?: string;
|
||||
onStage: (path: string, repo?: string) => void;
|
||||
onUnstage: (path: string, repo?: string) => void;
|
||||
}) {
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -130,7 +143,7 @@ function StageButton({
|
||||
className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnstage(path);
|
||||
onUnstage(path, repo);
|
||||
}}
|
||||
>
|
||||
<IconCheck className="h-3 w-3 group-hover/unstage:hidden" />
|
||||
@@ -145,7 +158,7 @@ function StageButton({
|
||||
className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStage(path);
|
||||
onStage(path, repo);
|
||||
}}
|
||||
>
|
||||
<IconPlus className="h-2.5 w-2.5" />
|
||||
@@ -155,11 +168,13 @@ function StageButton({
|
||||
|
||||
function FileRowActions({
|
||||
path,
|
||||
repo,
|
||||
onDiscard,
|
||||
onEditFile,
|
||||
}: {
|
||||
path: string;
|
||||
onDiscard: (path: string) => void;
|
||||
repo?: string;
|
||||
onDiscard: (path: string, repo?: string) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -171,7 +186,7 @@ function FileRowActions({
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDiscard(path);
|
||||
onDiscard(path, repo);
|
||||
}}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -9,12 +9,17 @@ interface GitOps {
|
||||
pull: (rebase?: boolean) => Promise<GitOperationResult>;
|
||||
push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise<GitOperationResult>;
|
||||
rebase: (baseBranch: string) => Promise<GitOperationResult>;
|
||||
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
|
||||
stage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
unstage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
discard: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
|
||||
reset: (commitSHA: string, mode: "soft" | "hard") => Promise<GitOperationResult>;
|
||||
commit: (
|
||||
message: string,
|
||||
stageAll?: boolean,
|
||||
amend?: boolean,
|
||||
repo?: string,
|
||||
) => Promise<GitOperationResult>;
|
||||
stage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
unstage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
discard: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
revertCommit: (commitSHA: string, repo?: string) => Promise<GitOperationResult>;
|
||||
reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise<GitOperationResult>;
|
||||
createPR: (
|
||||
title: string,
|
||||
body: string,
|
||||
@@ -72,8 +77,8 @@ export function useChangesGitHandlers(
|
||||
handleGitOperation(() => gitOps.push({ force: true }), "Force push");
|
||||
}, [handleGitOperation, gitOps]);
|
||||
const handleRevertCommit = useCallback(
|
||||
(sha: string) => {
|
||||
handleGitOperation(() => gitOps.revertCommit(sha), "Revert commit");
|
||||
(sha: string, repo?: string) => {
|
||||
handleGitOperation(() => gitOps.revertCommit(sha, repo), "Revert commit");
|
||||
},
|
||||
[handleGitOperation, gitOps],
|
||||
);
|
||||
@@ -96,22 +101,27 @@ function useChangesDiscardAmendHandlers(
|
||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
|
||||
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
|
||||
const [filesToDiscard, setFilesToDiscard] = useState<string[] | null>(null);
|
||||
// Multi-repo: remember the clicked file's repo so the discard op routes to
|
||||
// the right git repo. Path alone is ambiguous when two repos share a name.
|
||||
const [repoToDiscard, setRepoToDiscard] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleDiscardClick = useCallback((filePath: string) => {
|
||||
const handleDiscardClick = useCallback((filePath: string, repo?: string) => {
|
||||
setFileToDiscard(filePath);
|
||||
setRepoToDiscard(repo);
|
||||
setFilesToDiscard(null);
|
||||
setShowDiscardDialog(true);
|
||||
}, []);
|
||||
const handleBulkDiscardClick = useCallback((paths: string[]) => {
|
||||
setFilesToDiscard(paths);
|
||||
setFileToDiscard(null);
|
||||
setRepoToDiscard(undefined);
|
||||
setShowDiscardDialog(true);
|
||||
}, []);
|
||||
const handleDiscardConfirm = useCallback(async () => {
|
||||
const paths = filesToDiscard ?? (fileToDiscard ? [fileToDiscard] : null);
|
||||
if (!paths) return;
|
||||
try {
|
||||
const result = await gitOps.discard(paths);
|
||||
const result = await gitOps.discard(paths, repoToDiscard);
|
||||
if (!result.success)
|
||||
toast({
|
||||
title: "Failed to discard changes",
|
||||
@@ -128,24 +138,34 @@ function useChangesDiscardAmendHandlers(
|
||||
setShowDiscardDialog(false);
|
||||
setFileToDiscard(null);
|
||||
setFilesToDiscard(null);
|
||||
setRepoToDiscard(undefined);
|
||||
}
|
||||
}, [fileToDiscard, filesToDiscard, gitOps, toast]);
|
||||
}, [fileToDiscard, filesToDiscard, repoToDiscard, gitOps, toast]);
|
||||
|
||||
// Amend dialog state (for editing last commit message directly)
|
||||
const [amendDialogOpen, setAmendDialogOpen] = useState(false);
|
||||
const [amendMessage, setAmendMessage] = useState("");
|
||||
// Multi-repo: capture the commit's repo at click time so the amend lands in
|
||||
// the right git repo. Path/SHA alone can't be disambiguated when each repo
|
||||
// has its own HEAD.
|
||||
const [amendRepo, setAmendRepo] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleOpenAmendDialog = useCallback((currentMessage: string) => {
|
||||
const handleOpenAmendDialog = useCallback((currentMessage: string, repo?: string) => {
|
||||
setAmendMessage(currentMessage);
|
||||
setAmendRepo(repo);
|
||||
setAmendDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAmend = useCallback(async () => {
|
||||
if (!amendMessage.trim()) return;
|
||||
setAmendDialogOpen(false);
|
||||
await handleGitOperation(() => gitOps.commit(amendMessage.trim(), false, true), "Amend commit");
|
||||
await handleGitOperation(
|
||||
() => gitOps.commit(amendMessage.trim(), false, true, amendRepo),
|
||||
"Amend commit",
|
||||
);
|
||||
setAmendMessage("");
|
||||
}, [amendMessage, handleGitOperation, gitOps]);
|
||||
setAmendRepo(undefined);
|
||||
}, [amendMessage, amendRepo, handleGitOperation, gitOps]);
|
||||
|
||||
return {
|
||||
showDiscardDialog,
|
||||
@@ -168,9 +188,13 @@ function useChangesDiscardAmendHandlers(
|
||||
function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperationFn) {
|
||||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||||
const [resetCommitSha, setResetCommitSha] = useState<string | null>(null);
|
||||
// Multi-repo: capture the commit's repo so reset runs against the right
|
||||
// git repo. Without it, reset hits the workspace root and fails.
|
||||
const [resetRepo, setResetRepo] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleOpenResetDialog = useCallback((sha: string) => {
|
||||
const handleOpenResetDialog = useCallback((sha: string, repo?: string) => {
|
||||
setResetCommitSha(sha);
|
||||
setResetRepo(repo);
|
||||
setResetDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -179,10 +203,11 @@ function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperatio
|
||||
if (!resetCommitSha) return;
|
||||
setResetDialogOpen(false);
|
||||
const operationName = mode === "hard" ? "Hard reset" : "Soft reset";
|
||||
await handleGitOperation(() => gitOps.reset(resetCommitSha, mode), operationName);
|
||||
await handleGitOperation(() => gitOps.reset(resetCommitSha, mode, resetRepo), operationName);
|
||||
setResetCommitSha(null);
|
||||
setResetRepo(undefined);
|
||||
},
|
||||
[resetCommitSha, handleGitOperation, gitOps],
|
||||
[resetCommitSha, resetRepo, handleGitOperation, gitOps],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { render, screen, cleanup, fireEvent, within } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
vi.mock("./changes-panel-file-row", () => ({
|
||||
@@ -11,7 +11,11 @@ vi.mock("./changes-panel-file-row", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./commit-row", () => ({
|
||||
CommitRow: () => null,
|
||||
CommitRow: ({ commit, isLatest }: { commit: { commit_sha: string }; isLatest: boolean }) => (
|
||||
<li data-testid="commit-row" data-sha={commit.commit_sha} data-is-latest={String(isLatest)}>
|
||||
{commit.commit_sha}
|
||||
</li>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-multi-select", () => ({
|
||||
@@ -23,7 +27,9 @@ vi.mock("@/hooks/use-multi-select", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FileListSection } from "./changes-panel-timeline";
|
||||
import { FileListSection, CommitsSection } from "./changes-panel-timeline";
|
||||
|
||||
const REPO_HEADER_TID = "changes-repo-header";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -62,7 +68,7 @@ describe("FileListSection — multi-repo grouping", () => {
|
||||
files={[file("a.ts"), file("b.ts")]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryAllByTestId("changes-repo-header")).toHaveLength(0);
|
||||
expect(screen.queryAllByTestId(REPO_HEADER_TID)).toHaveLength(0);
|
||||
expect(screen.getAllByTestId("file-row")).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -81,7 +87,7 @@ describe("FileListSection — multi-repo grouping", () => {
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const headers = screen.getAllByTestId("changes-repo-header");
|
||||
const headers = screen.getAllByTestId(REPO_HEADER_TID);
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0].textContent).toContain("frontend");
|
||||
expect(headers[0].textContent).toContain("2");
|
||||
@@ -100,6 +106,130 @@ describe("FileListSection — multi-repo grouping", () => {
|
||||
files={[file("a.ts", "only-repo")]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("changes-repo-header").textContent).toContain("only-repo");
|
||||
expect(screen.getByTestId(REPO_HEADER_TID).textContent).toContain("only-repo");
|
||||
});
|
||||
|
||||
it("collapses one repo independently when its header is clicked", () => {
|
||||
render(
|
||||
<FileListSection
|
||||
{...baseProps}
|
||||
variant="unstaged"
|
||||
isLast={false}
|
||||
actionLabel="Stage all"
|
||||
onAction={() => undefined}
|
||||
files={[
|
||||
file("src/app.tsx", "frontend"),
|
||||
file("src/api.ts", "frontend"),
|
||||
file("handlers/task.go", "backend"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("file-row")).toHaveLength(3);
|
||||
|
||||
const groups = screen.getAllByTestId("changes-repo-group");
|
||||
const frontendGroup = groups.find(
|
||||
(g) => g.getAttribute("data-repository-name") === "frontend",
|
||||
)!;
|
||||
const frontendHeader = within(frontendGroup).getByTestId(REPO_HEADER_TID);
|
||||
|
||||
fireEvent.click(frontendHeader);
|
||||
|
||||
// frontend's two rows hidden, backend's one row still visible
|
||||
expect(screen.getAllByTestId("file-row")).toHaveLength(1);
|
||||
expect(frontendHeader.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(frontendHeader);
|
||||
expect(screen.getAllByTestId("file-row")).toHaveLength(3);
|
||||
expect(frontendHeader.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommitsSection", () => {
|
||||
type CommitProps = ComponentProps<typeof CommitsSection>;
|
||||
|
||||
function commit(sha: string, message: string, repo?: string): CommitProps["commits"][number] {
|
||||
return {
|
||||
commit_sha: sha,
|
||||
commit_message: message,
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
pushed: false,
|
||||
repository_name: repo,
|
||||
};
|
||||
}
|
||||
|
||||
it("starts collapsed by default — commits are reference info, not the primary signal", () => {
|
||||
render(
|
||||
<CommitsSection
|
||||
commits={[commit("abc123", "first")]}
|
||||
isLast
|
||||
/>,
|
||||
);
|
||||
// Header is rendered, but commit rows are hidden until the user expands.
|
||||
expect(screen.queryByTestId("commit-row")).toBeNull();
|
||||
const toggle = screen.getByTestId("commits-section-collapse-toggle");
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("groups commits per repo when 2+ repos are present", () => {
|
||||
render(
|
||||
<CommitsSection
|
||||
commits={[
|
||||
commit("c1", "frontend change", "frontend"),
|
||||
commit("c2", "backend change", "backend"),
|
||||
commit("c3", "another frontend", "frontend"),
|
||||
]}
|
||||
isLast
|
||||
/>,
|
||||
);
|
||||
// Expand the section first so the inner repo headers and commit rows render.
|
||||
fireEvent.click(screen.getByTestId("commits-section-collapse-toggle"));
|
||||
|
||||
const headers = screen.getAllByTestId("commits-repo-header");
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0].textContent).toContain("frontend");
|
||||
expect(headers[0].textContent).toContain("2");
|
||||
expect(headers[1].textContent).toContain("backend");
|
||||
expect(headers[1].textContent).toContain("1");
|
||||
expect(screen.getAllByTestId("commit-row")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders flat (no per-repo headers) for single-repo or untagged commits", () => {
|
||||
render(
|
||||
<CommitsSection
|
||||
commits={[commit("c1", "msg"), commit("c2", "msg")]}
|
||||
isLast
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("commits-section-collapse-toggle"));
|
||||
expect(screen.queryAllByTestId("commits-repo-header")).toHaveLength(0);
|
||||
expect(screen.getAllByTestId("commit-row")).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Regression: previously isLatest was computed against the merged list, so
|
||||
// only ONE commit globally was marked latest. Each repo's newest unpushed
|
||||
// commit must be latest in its own group — otherwise revert/amend buttons
|
||||
// are absent on every repo except one, and clicking revert via context menu
|
||||
// hits the backend's "can only revert latest commit" gate.
|
||||
it("marks each repo's newest unpushed commit as latest (per-repo, not global)", () => {
|
||||
render(
|
||||
<CommitsSection
|
||||
commits={[
|
||||
// Insertion order is newest-first within each repo.
|
||||
commit("frontend-new", "frontend latest", "frontend"),
|
||||
commit("frontend-old", "frontend older", "frontend"),
|
||||
commit("backend-only", "backend latest", "backend"),
|
||||
]}
|
||||
isLast
|
||||
onRevertCommit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("commits-section-collapse-toggle"));
|
||||
const rows = screen.getAllByTestId("commit-row");
|
||||
const latestByShas = rows
|
||||
.filter((r) => r.getAttribute("data-is-latest") === "true")
|
||||
.map((r) => r.getAttribute("data-sha"));
|
||||
expect(latestByShas.sort()).toEqual(["backend-only", "frontend-new"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { LineStat } from "@/components/diff-stat";
|
||||
import { FileStatusIcon } from "./file-status-icon";
|
||||
import { FileRow, BulkActionBar, DefaultActionButtons } from "./changes-panel-file-row";
|
||||
import { CommitRow, type CommitItem } from "./commit-row";
|
||||
import { groupByRepositoryName } from "@/lib/group-by-repo";
|
||||
|
||||
// --- Timeline visual components ---
|
||||
|
||||
@@ -38,27 +39,7 @@ type ChangedFile = {
|
||||
repositoryName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups changed files by repository name, preserving original order within
|
||||
* each group. Returns an empty-name leading group when files lack a
|
||||
* repository name (single-repo workspaces) so the renderer can skip the
|
||||
* per-repo header in that case.
|
||||
*/
|
||||
function groupChangedFilesByRepository(
|
||||
files: ChangedFile[],
|
||||
): Array<{ repositoryName: string; files: ChangedFile[] }> {
|
||||
const order: string[] = [];
|
||||
const buckets = new Map<string, ChangedFile[]>();
|
||||
for (const file of files) {
|
||||
const name = file.repositoryName ?? "";
|
||||
if (!buckets.has(name)) {
|
||||
buckets.set(name, []);
|
||||
order.push(name);
|
||||
}
|
||||
buckets.get(name)!.push(file);
|
||||
}
|
||||
return order.map((name) => ({ repositoryName: name, files: buckets.get(name)! }));
|
||||
}
|
||||
// Per-repo grouping is shared with the Review dialog — see @/lib/group-by-repo.
|
||||
|
||||
// --- Timeline section dot colors ---
|
||||
const DOT_COLORS = {
|
||||
@@ -81,6 +62,7 @@ function TimelineSection({
|
||||
isLast,
|
||||
children,
|
||||
collapsible = true,
|
||||
defaultCollapsed = false,
|
||||
"data-testid": testId,
|
||||
}: {
|
||||
dotColor: string;
|
||||
@@ -90,9 +72,10 @@ function TimelineSection({
|
||||
isLast?: boolean;
|
||||
children?: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultCollapsed?: boolean;
|
||||
"data-testid"?: string;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
const canCollapse = collapsible && !!label;
|
||||
|
||||
return (
|
||||
@@ -151,11 +134,16 @@ type CommitsSectionProps = {
|
||||
commits: CommitItem[];
|
||||
isLast: boolean;
|
||||
onOpenCommitDetail?: (sha: string) => void;
|
||||
onRevertCommit?: (sha: string) => void;
|
||||
onAmendCommit?: (currentMessage: string) => void;
|
||||
onResetToCommit?: (sha: string) => void;
|
||||
// Multi-repo: handlers receive the commit's repository_name so amend/revert/
|
||||
// reset land in the right git repo. Without it the op runs at the workspace
|
||||
// root, which fails ("can only revert latest commit") for multi-repo tasks.
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onAmendCommit?: (currentMessage: string, repo?: string) => void;
|
||||
onResetToCommit?: (sha: string, repo?: string) => void;
|
||||
};
|
||||
|
||||
// Commits grouping shares the helper above with files — see @/lib/group-by-repo.
|
||||
|
||||
export function CommitsSection({
|
||||
commits,
|
||||
isLast,
|
||||
@@ -164,8 +152,8 @@ export function CommitsSection({
|
||||
onAmendCommit,
|
||||
onResetToCommit,
|
||||
}: CommitsSectionProps) {
|
||||
// Find the first unpushed commit for context menu actions (amend/revert only on latest unpushed)
|
||||
const firstUnpushedIndex = commits.findIndex((c) => c.pushed !== true);
|
||||
const groups = groupByRepositoryName(commits, (c) => c.repository_name);
|
||||
const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== "";
|
||||
|
||||
return (
|
||||
<TimelineSection
|
||||
@@ -173,25 +161,104 @@ export function CommitsSection({
|
||||
label="Commits"
|
||||
count={commits.length}
|
||||
isLast={isLast}
|
||||
// User preference: commits panel starts collapsed — the file changes are
|
||||
// the primary signal; commit history is reference info you expand on demand.
|
||||
defaultCollapsed
|
||||
data-testid="commits-section"
|
||||
>
|
||||
<ul data-testid="commits-list" className="space-y-0.5">
|
||||
{commits.map((commit, index) => (
|
||||
<CommitRow
|
||||
key={`${commit.commit_sha}-${index}`}
|
||||
commit={commit}
|
||||
isLatest={index === firstUnpushedIndex}
|
||||
onOpenCommitDetail={onOpenCommitDetail}
|
||||
onAmendCommit={commit.pushed ? undefined : onAmendCommit}
|
||||
onRevertCommit={commit.pushed ? undefined : onRevertCommit}
|
||||
onResetToCommit={commit.pushed ? undefined : onResetToCommit}
|
||||
/>
|
||||
))}
|
||||
{showRepoHeaders
|
||||
? groups.map((g) => (
|
||||
<CommitsRepoGroup
|
||||
key={g.repositoryName || "__no_repo__"}
|
||||
repositoryName={g.repositoryName}
|
||||
groupCommits={g.items}
|
||||
onOpenCommitDetail={onOpenCommitDetail}
|
||||
onAmendCommit={onAmendCommit}
|
||||
onRevertCommit={onRevertCommit}
|
||||
onResetToCommit={onResetToCommit}
|
||||
/>
|
||||
))
|
||||
: commits.map((commit, index) => (
|
||||
<CommitRow
|
||||
key={`${commit.commit_sha}-${index}`}
|
||||
commit={commit}
|
||||
// Single-repo: only the newest unpushed commit is "latest"
|
||||
// (revert/amend are gated on this in CommitRow).
|
||||
isLatest={index === commits.findIndex((c) => c.pushed !== true)}
|
||||
onOpenCommitDetail={onOpenCommitDetail}
|
||||
onAmendCommit={commit.pushed ? undefined : onAmendCommit}
|
||||
onRevertCommit={commit.pushed ? undefined : onRevertCommit}
|
||||
onResetToCommit={commit.pushed ? undefined : onResetToCommit}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</TimelineSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitsRepoGroup({
|
||||
repositoryName,
|
||||
groupCommits,
|
||||
onOpenCommitDetail,
|
||||
onAmendCommit,
|
||||
onRevertCommit,
|
||||
onResetToCommit,
|
||||
}: {
|
||||
repositoryName: string;
|
||||
groupCommits: CommitItem[];
|
||||
onOpenCommitDetail?: (sha: string) => void;
|
||||
onAmendCommit?: (currentMessage: string, repo?: string) => void;
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onResetToCommit?: (sha: string, repo?: string) => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// Each repo has its own "latest unpushed commit" — revert/amend in this
|
||||
// group must target THIS repo's newest, not the merged-list newest. Using
|
||||
// the global firstUnpushedIndex would mark only one commit across all repos
|
||||
// as latest and trigger "can only revert latest commit" everywhere else.
|
||||
const firstUnpushedInGroup = groupCommits.findIndex((c) => c.pushed !== true);
|
||||
return (
|
||||
<li
|
||||
data-testid="commits-repo-group"
|
||||
data-repository-name={repositoryName || ""}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1.5 px-1 py-0.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide cursor-pointer hover:text-foreground/80"
|
||||
data-testid="commits-repo-header"
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<IconChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
) : (
|
||||
<IconChevronDown className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{repositoryName || "Other"}</span>
|
||||
<span className="text-muted-foreground/50 normal-case tracking-normal">
|
||||
{groupCommits.length}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className="space-y-0.5">
|
||||
{groupCommits.map((commit, index) => (
|
||||
<CommitRow
|
||||
key={commit.commit_sha}
|
||||
commit={commit}
|
||||
isLatest={index === firstUnpushedInGroup}
|
||||
onOpenCommitDetail={onOpenCommitDetail}
|
||||
onAmendCommit={commit.pushed ? undefined : onAmendCommit}
|
||||
onRevertCommit={commit.pushed ? undefined : onRevertCommit}
|
||||
onResetToCommit={commit.pushed ? undefined : onResetToCommit}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Action buttons section (Create PR / Push) ---
|
||||
|
||||
type ActionButtonsSectionProps = {
|
||||
@@ -313,9 +380,11 @@ type FileListSectionProps = {
|
||||
onSecondaryAction?: () => void;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
// Multi-repo: handlers receive the file's repositoryName so each per-file op
|
||||
// hits the right git repo. Same-named files across repos collide by path.
|
||||
onStage: (path: string, repo?: string) => void;
|
||||
onUnstage: (path: string, repo?: string) => void;
|
||||
onDiscard: (path: string, repo?: string) => void;
|
||||
onBulkStage?: (paths: string[]) => void;
|
||||
onBulkUnstage?: (paths: string[]) => void;
|
||||
onBulkDiscard?: (paths: string[]) => void;
|
||||
@@ -335,13 +404,28 @@ function FileListBody(props: {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onOpenDiff: (path: string) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
onDiscard: (path: string) => void;
|
||||
onStage: (path: string, repo?: string) => void;
|
||||
onUnstage: (path: string, repo?: string) => void;
|
||||
onDiscard: (path: string, repo?: string) => void;
|
||||
}) {
|
||||
const { variant, files, pendingStageFiles, multiSelect } = props;
|
||||
const groups = useMemo(() => groupChangedFilesByRepository(files), [files]);
|
||||
const groups = useMemo(
|
||||
() => groupByRepositoryName(files, (f) => f.repositoryName),
|
||||
[files],
|
||||
);
|
||||
const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== "";
|
||||
// Per-repo collapsed state: keyed by repositoryName. Default expanded;
|
||||
// setting an entry to true collapses that group. Persists across re-renders
|
||||
// for the lifetime of this section instance (resets on unmount).
|
||||
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(() => new Set());
|
||||
const toggleRepo = useCallback((name: string) => {
|
||||
setCollapsedRepos((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderRow = (file: ChangedFile) => (
|
||||
<FileRow
|
||||
@@ -373,21 +457,31 @@ function FileListBody(props: {
|
||||
data-testid="changes-repo-group"
|
||||
data-repository-name={group.repositoryName || ""}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-1 py-0.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide"
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1.5 px-1 py-0.5 text-[11px] font-medium text-muted-foreground/80 uppercase tracking-wide cursor-pointer hover:text-foreground/80"
|
||||
data-testid="changes-repo-header"
|
||||
aria-expanded={!collapsedRepos.has(group.repositoryName)}
|
||||
onClick={() => toggleRepo(group.repositoryName)}
|
||||
>
|
||||
{collapsedRepos.has(group.repositoryName) ? (
|
||||
<IconChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
) : (
|
||||
<IconChevronDown className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{group.repositoryName || "Other"}</span>
|
||||
<span className="text-muted-foreground/50 normal-case tracking-normal">
|
||||
{group.files.length}
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5">{group.files.map(renderRow)}</ul>
|
||||
</button>
|
||||
{!collapsedRepos.has(group.repositoryName) && (
|
||||
<ul className="space-y-0.5">{group.items.map(renderRow)}</ul>
|
||||
)}
|
||||
</li>
|
||||
) : (
|
||||
// Single-repo flat list: render rows inline so the DOM shape stays
|
||||
// identical to the pre-multi-repo behavior.
|
||||
group.files.map(renderRow)
|
||||
group.items.map(renderRow)
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
|
||||
@@ -171,6 +171,8 @@ type MergedCommit = {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
pushed: boolean;
|
||||
/** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */
|
||||
repository_name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -185,6 +187,8 @@ export function mergeCommits(
|
||||
commit_message: string;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
/** Multi-repo: name of the repo this commit was made in. */
|
||||
repository_name?: string;
|
||||
}[],
|
||||
prCommits: { sha: string; message: string; additions: number; deletions: number }[],
|
||||
): MergedCommit[] {
|
||||
@@ -289,11 +293,17 @@ type ChangesPanelBodyProps = {
|
||||
onEditFile: (path: string) => void;
|
||||
onOpenCommitDetail?: (sha: string) => void;
|
||||
onOpenReview?: () => void;
|
||||
onRevertCommit?: (sha: string) => void;
|
||||
// Multi-repo: handlers carry the commit's repository_name so revert/amend/
|
||||
// reset target the right git repo. Without it the op runs at the workspace
|
||||
// root and fails ("can only revert latest commit") on multi-repo tasks.
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
onStage: (path: string) => Promise<void>;
|
||||
onUnstage: (path: string) => Promise<void>;
|
||||
// Multi-repo: callers pass the file's repository_name so the agentctl op
|
||||
// routes to the right repo. Same-named files across repos can't be
|
||||
// disambiguated by path alone.
|
||||
onStage: (path: string, repo?: string) => Promise<void>;
|
||||
onUnstage: (path: string, repo?: string) => Promise<void>;
|
||||
onBulkStage: (paths: string[]) => void;
|
||||
onBulkUnstage: (paths: string[]) => void;
|
||||
onBulkDiscard: (paths: string[]) => void;
|
||||
@@ -625,8 +635,8 @@ const ChangesPanel = memo(function ChangesPanel({
|
||||
onOpenReview={onOpenReview}
|
||||
onStageAll={git.stageAll}
|
||||
onUnstageAll={git.unstageAll}
|
||||
onStage={(path) => git.stageFile([path]).then(() => undefined)}
|
||||
onUnstage={(path) => git.unstageFile([path]).then(() => undefined)}
|
||||
onStage={(path, repo) => git.stageFile([path], repo).then(() => undefined)}
|
||||
onUnstage={(path, repo) => git.unstageFile([path], repo).then(() => undefined)}
|
||||
onBulkStage={(paths) => {
|
||||
git.stageFile(paths).catch(() => {});
|
||||
}}
|
||||
|
||||
@@ -22,6 +22,8 @@ export type CommitItem = {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
pushed?: boolean;
|
||||
/** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */
|
||||
repository_name?: string;
|
||||
};
|
||||
|
||||
/** Context menu for commit items */
|
||||
@@ -36,9 +38,12 @@ function CommitContextMenu({
|
||||
children: React.ReactNode;
|
||||
commit: CommitItem;
|
||||
isLatest: boolean;
|
||||
onAmendCommit?: (currentMessage: string) => void;
|
||||
onRevertCommit?: (sha: string) => void;
|
||||
onResetToCommit?: (sha: string) => void;
|
||||
// Multi-repo: handlers receive the commit's repository_name so the
|
||||
// amend/revert/reset op runs in the right git repo. Without it, ops hit
|
||||
// the workspace root which fails on multi-repo task workspaces.
|
||||
onAmendCommit?: (currentMessage: string, repo?: string) => void;
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onResetToCommit?: (sha: string, repo?: string) => void;
|
||||
}) {
|
||||
const hasActions = onAmendCommit || onRevertCommit || onResetToCommit;
|
||||
|
||||
@@ -51,19 +56,25 @@ function CommitContextMenu({
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{isLatest && onAmendCommit && (
|
||||
<ContextMenuItem onSelect={() => onAmendCommit(commit.commit_message)}>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onAmendCommit(commit.commit_message, commit.repository_name)}
|
||||
>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
Amend message
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{isLatest && onRevertCommit && (
|
||||
<ContextMenuItem onSelect={() => onRevertCommit(commit.commit_sha)}>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onRevertCommit(commit.commit_sha, commit.repository_name)}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
Revert commit
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onResetToCommit && (
|
||||
<ContextMenuItem onSelect={() => onResetToCommit(commit.commit_sha)}>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onResetToCommit(commit.commit_sha, commit.repository_name)}
|
||||
>
|
||||
<IconHistoryToggle className="h-3.5 w-3.5" />
|
||||
Reset to this commit
|
||||
</ContextMenuItem>
|
||||
@@ -83,9 +94,12 @@ function CommitRowActions({
|
||||
}: {
|
||||
commit: CommitItem;
|
||||
isLatest: boolean;
|
||||
onAmendCommit?: (currentMessage: string) => void;
|
||||
onRevertCommit?: (sha: string) => void;
|
||||
onResetToCommit?: (sha: string) => void;
|
||||
// Multi-repo: handlers receive the commit's repository_name so the
|
||||
// amend/revert/reset op runs in the right git repo. Without it, ops hit
|
||||
// the workspace root which fails on multi-repo task workspaces.
|
||||
onAmendCommit?: (currentMessage: string, repo?: string) => void;
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onResetToCommit?: (sha: string, repo?: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="absolute right-2 top-0 bottom-0 hidden group-hover:flex items-center gap-1">
|
||||
@@ -98,7 +112,7 @@ function CommitRowActions({
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAmendCommit(commit.commit_message);
|
||||
onAmendCommit(commit.commit_message, commit.repository_name);
|
||||
}}
|
||||
>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
@@ -116,7 +130,7 @@ function CommitRowActions({
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRevertCommit(commit.commit_sha);
|
||||
onRevertCommit(commit.commit_sha, commit.repository_name);
|
||||
}}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
@@ -134,7 +148,7 @@ function CommitRowActions({
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResetToCommit(commit.commit_sha);
|
||||
onResetToCommit(commit.commit_sha, commit.repository_name);
|
||||
}}
|
||||
>
|
||||
<IconHistoryToggle className="h-3.5 w-3.5" />
|
||||
@@ -159,9 +173,12 @@ export function CommitRow({
|
||||
commit: CommitItem;
|
||||
isLatest: boolean;
|
||||
onOpenCommitDetail?: (sha: string) => void;
|
||||
onAmendCommit?: (currentMessage: string) => void;
|
||||
onRevertCommit?: (sha: string) => void;
|
||||
onResetToCommit?: (sha: string) => void;
|
||||
// Multi-repo: handlers receive the commit's repository_name so the
|
||||
// amend/revert/reset op runs in the right git repo. Without it, ops hit
|
||||
// the workspace root which fails on multi-repo task workspaces.
|
||||
onAmendCommit?: (currentMessage: string, repo?: string) => void;
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onResetToCommit?: (sha: string, repo?: string) => void;
|
||||
}) {
|
||||
const showActions = onResetToCommit || (isLatest && (onAmendCommit || onRevertCommit));
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
|
||||
import type { FileInfo } from "@/lib/state/slices";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import {
|
||||
@@ -433,15 +434,13 @@ export function useMobileGitActions(
|
||||
setPrDialogOpen: (v: boolean) => void,
|
||||
) {
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
pull,
|
||||
push,
|
||||
rebase,
|
||||
merge,
|
||||
commit,
|
||||
createPR,
|
||||
isLoading: isGitLoading,
|
||||
} = useGitOperations(sessionId ?? null);
|
||||
// SessionGit's commit fans out per-repo for multi-repo workspaces; the raw
|
||||
// useGitOperations.commit hits the workspace root and fails on multi-repo
|
||||
// tasks. Pull from useGitOperations only what SessionGit doesn't override.
|
||||
const { pull, push, rebase, merge, createPR, isLoading: isGitLoading } = useGitOperations(
|
||||
sessionId ?? null,
|
||||
);
|
||||
const { commit } = useSessionGit(sessionId ?? null);
|
||||
const handleGitOperation = useGitToast();
|
||||
|
||||
const handlePull = useCallback(
|
||||
|
||||
@@ -143,7 +143,7 @@ export function useCommitDiffs(activeSessionId: string | null | undefined) {
|
||||
|
||||
export function useGitStagingActions(
|
||||
activeSessionId: string | null | undefined,
|
||||
changedFiles: unknown[],
|
||||
changedFiles: Array<{ path?: string; repository_name?: string } | unknown>,
|
||||
) {
|
||||
const gitOps = useGitOperations(activeSessionId ?? null);
|
||||
const [pendingStageFiles, setPendingStageFiles] = useState<Set<string>>(new Set());
|
||||
@@ -153,11 +153,31 @@ export function useGitStagingActions(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [changedFiles]);
|
||||
|
||||
// Multi-repo: each agentctl git op needs the repo subpath, otherwise the
|
||||
// command runs at the workspace root (the task holder dir) and silently
|
||||
// no-ops. Build a path → repository_name map from changedFiles.
|
||||
const repoForPath = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const f of changedFiles) {
|
||||
if (!f || typeof f !== "object") continue;
|
||||
const path = (f as { path?: unknown }).path;
|
||||
const repo = (f as { repository_name?: unknown }).repository_name;
|
||||
if (typeof path === "string" && typeof repo === "string" && repo) {
|
||||
m.set(path, repo);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [changedFiles]);
|
||||
|
||||
// The FileRow now passes the file's repository_name explicitly, which avoids
|
||||
// path-based lookup collisions when two repos share a filename. Fall back to
|
||||
// the lookup only if the caller didn't pass one (older call sites).
|
||||
const handleStage = useCallback(
|
||||
async (path: string) => {
|
||||
async (path: string, repo?: string) => {
|
||||
const effectiveRepo = repo ?? repoForPath.get(path);
|
||||
setPendingStageFiles((prev) => new Set(prev).add(path));
|
||||
try {
|
||||
await gitOps.stage([path]);
|
||||
await gitOps.stage([path], effectiveRepo);
|
||||
} catch {
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -166,14 +186,15 @@ export function useGitStagingActions(
|
||||
});
|
||||
}
|
||||
},
|
||||
[gitOps],
|
||||
[gitOps, repoForPath],
|
||||
);
|
||||
|
||||
const handleUnstage = useCallback(
|
||||
async (path: string) => {
|
||||
async (path: string, repo?: string) => {
|
||||
const effectiveRepo = repo ?? repoForPath.get(path);
|
||||
setPendingStageFiles((prev) => new Set(prev).add(path));
|
||||
try {
|
||||
await gitOps.unstage([path]);
|
||||
await gitOps.unstage([path], effectiveRepo);
|
||||
} catch {
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -182,7 +203,7 @@ export function useGitStagingActions(
|
||||
});
|
||||
}
|
||||
},
|
||||
[gitOps],
|
||||
[gitOps, repoForPath],
|
||||
);
|
||||
|
||||
return { pendingStageFiles, handleStage, handleUnstage };
|
||||
@@ -241,18 +262,22 @@ export function useFilesPanelTab(
|
||||
export function useDiscardDialog(activeSessionId: string | null | undefined) {
|
||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
|
||||
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
|
||||
// Multi-repo: capture the file's repo at click time so the discard op runs
|
||||
// against the right git repo (path-only routing collides on same-named files).
|
||||
const [repoToDiscard, setRepoToDiscard] = useState<string | undefined>(undefined);
|
||||
const gitOps = useGitOperations(activeSessionId ?? null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleDiscardClick = useCallback((filePath: string) => {
|
||||
const handleDiscardClick = useCallback((filePath: string, repo?: string) => {
|
||||
setFileToDiscard(filePath);
|
||||
setRepoToDiscard(repo);
|
||||
setShowDiscardDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleDiscardConfirm = useCallback(async () => {
|
||||
if (!fileToDiscard) return;
|
||||
try {
|
||||
const result = await gitOps.discard([fileToDiscard]);
|
||||
const result = await gitOps.discard([fileToDiscard], repoToDiscard);
|
||||
if (!result.success)
|
||||
toast({
|
||||
title: "Failed to discard changes",
|
||||
@@ -268,8 +293,9 @@ export function useDiscardDialog(activeSessionId: string | null | undefined) {
|
||||
} finally {
|
||||
setShowDiscardDialog(false);
|
||||
setFileToDiscard(null);
|
||||
setRepoToDiscard(undefined);
|
||||
}
|
||||
}, [fileToDiscard, gitOps, toast]);
|
||||
}, [fileToDiscard, repoToDiscard, gitOps, toast]);
|
||||
|
||||
return {
|
||||
showDiscardDialog,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
PRBranchSummary,
|
||||
} from "./vcs-dialog-fields";
|
||||
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
||||
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
|
||||
import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
|
||||
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
|
||||
@@ -396,7 +397,10 @@ export function VcsDialogsProvider({
|
||||
const { toast } = useToast();
|
||||
const gitWithFeedback = useGitWithFeedback();
|
||||
const gitStatus = useSessionGitStatus(sessionId);
|
||||
const { commit, createPR, isLoading: isGitLoading } = useGitOperations(sessionId);
|
||||
// Use SessionGit so commit fans out per-repo for multi-repo workspaces.
|
||||
// useGitOperations.commit hits the workspace root, which fails for multi-repo
|
||||
// tasks because the task root isn't itself a git repo (exit 1).
|
||||
const { commit, createPR, isLoading: isGitLoading } = useSessionGit(sessionId);
|
||||
const isUtilityConfigured = useIsUtilityConfigured();
|
||||
const {
|
||||
isGeneratingCommitMessage,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { groupPathsByRepoName } from "./use-session-git";
|
||||
|
||||
describe("groupPathsByRepoName", () => {
|
||||
it("splits paths by repository_name, preserving insertion order per bucket", () => {
|
||||
const lookup = new Map([
|
||||
["src/app.tsx", "frontend"],
|
||||
["src/api.ts", "frontend"],
|
||||
["handlers/task.go", "backend"],
|
||||
]);
|
||||
const out = groupPathsByRepoName(["src/app.tsx", "handlers/task.go", "src/api.ts"], lookup);
|
||||
expect(out.get("frontend")).toEqual(["src/app.tsx", "src/api.ts"]);
|
||||
expect(out.get("backend")).toEqual(["handlers/task.go"]);
|
||||
expect(out.size).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to empty-string bucket for paths missing a repo", () => {
|
||||
const out = groupPathsByRepoName(["unknown.txt"], new Map());
|
||||
expect(out.get("")).toEqual(["unknown.txt"]);
|
||||
});
|
||||
|
||||
it("preserves single-repo callers (everything in one bucket)", () => {
|
||||
const lookup = new Map([
|
||||
["a.ts", "only"],
|
||||
["b.ts", "only"],
|
||||
]);
|
||||
const out = groupPathsByRepoName(["a.ts", "b.ts"], lookup);
|
||||
expect(out.size).toBe(1);
|
||||
expect(out.get("only")).toEqual(["a.ts", "b.ts"]);
|
||||
});
|
||||
});
|
||||
@@ -53,14 +53,27 @@ export type SessionGit = {
|
||||
rebase: (baseBranch: string) => Promise<GitOperationResult>;
|
||||
merge: (baseBranch: string) => Promise<GitOperationResult>;
|
||||
abort: (operation: "merge" | "rebase") => Promise<GitOperationResult>;
|
||||
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
|
||||
stage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
stageFile: (paths: string[]) => Promise<GitOperationResult>;
|
||||
// Multi-repo: when `repo` is omitted, commit fans out one call per repo with
|
||||
// staged changes so each repo gets its own commit. With `repo`, only that
|
||||
// repo is committed.
|
||||
commit: (
|
||||
message: string,
|
||||
stageAll?: boolean,
|
||||
amend?: boolean,
|
||||
repo?: string,
|
||||
) => Promise<GitOperationResult>;
|
||||
// Multi-repo: when `repo` is provided, the op runs against that repo only —
|
||||
// use this for per-file actions to avoid path-based lookup collisions
|
||||
// (same-named files like README.md exist in multiple repos). When `repo`
|
||||
// is omitted, the op falls back to a path-based lookup against allFiles
|
||||
// (works for unique paths) or fans out across every repo present.
|
||||
stage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
stageFile: (paths: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
stageAll: () => Promise<GitOperationResult>;
|
||||
unstage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
unstageFile: (paths: string[]) => Promise<GitOperationResult>;
|
||||
unstage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
unstageFile: (paths: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
unstageAll: () => Promise<GitOperationResult>;
|
||||
discard: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
discard: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
|
||||
renameBranch: (newName: string) => Promise<GitOperationResult>;
|
||||
reset: (commitSHA: string, mode: "soft" | "hard") => Promise<GitOperationResult>;
|
||||
@@ -72,6 +85,29 @@ export type SessionGit = {
|
||||
) => Promise<PRCreateResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups paths into per-repo buckets using a path → repository_name lookup.
|
||||
* Paths missing a known repo land under "" (the single-repo bucket) so legacy
|
||||
* single-repo workspaces and stray entries stay correct. Insertion order in
|
||||
* `paths` is preserved within each bucket.
|
||||
*
|
||||
* Exported for testing — also used internally by useSessionGit's stage/unstage
|
||||
* fan-out, where every per-repo bucket becomes one agentctl call.
|
||||
*/
|
||||
export function groupPathsByRepoName(
|
||||
paths: string[],
|
||||
repoForPath: Map<string, string>,
|
||||
): Map<string, string[]> {
|
||||
const buckets = new Map<string, string[]>();
|
||||
for (const p of paths) {
|
||||
const repo = repoForPath.get(p) ?? "";
|
||||
const list = buckets.get(repo);
|
||||
if (list) list.push(p);
|
||||
else buckets.set(repo, [p]);
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SessionGit's flat file list. For multi-repo workspaces it
|
||||
* stamps each FileInfo with its repository_name so consumers can group;
|
||||
@@ -125,16 +161,122 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
const hasStaged = stagedFiles.length > 0;
|
||||
const hasCommits = commits.length > 0;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
const stageAll = useCallback(async () => gitOps.stage(), [gitOps.stage]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
const unstageAll = useCallback(async () => gitOps.unstage(), [gitOps.unstage]);
|
||||
// Multi-repo path-to-repo lookup: agentctl's stage/unstage/discard endpoints
|
||||
// need an explicit repo subpath (the repo dir basename). Without it the op
|
||||
// runs at the workspace root which, for multi-repo task workspaces, isn't
|
||||
// a git repo and silently no-ops.
|
||||
const repoForPath = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const f of allFiles) {
|
||||
if (f.repository_name) m.set(f.path, f.repository_name);
|
||||
}
|
||||
return m;
|
||||
}, [allFiles]);
|
||||
// List of distinct repo names present in the current files; empty when single-repo.
|
||||
const reposInFiles = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const f of allFiles) if (f.repository_name) seen.add(f.repository_name);
|
||||
return Array.from(seen);
|
||||
}, [allFiles]);
|
||||
|
||||
const groupPathsByRepo = useCallback(
|
||||
(paths: string[]): Map<string, string[]> => groupPathsByRepoName(paths, repoForPath),
|
||||
[repoForPath],
|
||||
);
|
||||
|
||||
// Fan out a stage-all / unstage-all (no paths) across every repo when multi-repo.
|
||||
// Returns the last op's result (UI only checks success/error of the most recent).
|
||||
const runOnAllRepos = useCallback(
|
||||
async (op: (paths: string[] | undefined, repo?: string) => Promise<GitOperationResult>) => {
|
||||
if (reposInFiles.length <= 1) {
|
||||
return op(undefined, reposInFiles[0]);
|
||||
}
|
||||
let last: GitOperationResult | undefined;
|
||||
for (const repo of reposInFiles) {
|
||||
last = await op(undefined, repo);
|
||||
}
|
||||
return last as GitOperationResult;
|
||||
},
|
||||
[reposInFiles],
|
||||
);
|
||||
|
||||
const stageAll = useCallback(
|
||||
async () => runOnAllRepos((paths, repo) => gitOps.stage(paths, repo)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
[runOnAllRepos, gitOps.stage],
|
||||
);
|
||||
const unstageAll = useCallback(
|
||||
async () => runOnAllRepos((paths, repo) => gitOps.unstage(paths, repo)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
[runOnAllRepos, gitOps.unstage],
|
||||
);
|
||||
|
||||
// Multi-repo commit: when the caller doesn't pin a repo, commit each repo
|
||||
// that has staged files separately (with the same message). Committing at
|
||||
// the workspace root for a multi-repo task fails because the task root
|
||||
// isn't itself a git repo — that's the "exit 1" the user saw.
|
||||
const commit = useCallback(
|
||||
async (
|
||||
message: string,
|
||||
stageAll: boolean = true,
|
||||
amend: boolean = false,
|
||||
repo?: string,
|
||||
): Promise<GitOperationResult> => {
|
||||
if (repo !== undefined) {
|
||||
return gitOps.commit(message, stageAll, amend, repo || undefined);
|
||||
}
|
||||
const reposWithStaged = Array.from(
|
||||
new Set(
|
||||
stagedFiles
|
||||
.map((f) => f.repository_name)
|
||||
.filter((n): n is string => Boolean(n)),
|
||||
),
|
||||
);
|
||||
// Single-repo (or repo-less): one call, no repo arg.
|
||||
if (reposWithStaged.length === 0) {
|
||||
return gitOps.commit(message, stageAll, amend);
|
||||
}
|
||||
// Multi-repo: one commit per repo with staged changes. Stop on first
|
||||
// failure so the user sees the actual error instead of cascading ones.
|
||||
let last: GitOperationResult | undefined;
|
||||
for (const r of reposWithStaged) {
|
||||
last = await gitOps.commit(message, stageAll, amend, r);
|
||||
if (!last.success) return last;
|
||||
}
|
||||
return last as GitOperationResult;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
[gitOps.commit, stagedFiles],
|
||||
);
|
||||
|
||||
// runPerRepo invokes `op` once per repo bucket. When the caller supplies an
|
||||
// explicit `repo`, all paths route there as a single call (skipping the
|
||||
// path → repo lookup that can collide on same-named files). Otherwise paths
|
||||
// are split by repository_name from allFiles.
|
||||
const runPerRepo = useCallback(
|
||||
async (
|
||||
paths: string[],
|
||||
explicitRepo: string | undefined,
|
||||
op: (paths: string[], repo: string | undefined) => Promise<GitOperationResult>,
|
||||
): Promise<GitOperationResult> => {
|
||||
if (explicitRepo !== undefined) {
|
||||
return op(paths, explicitRepo || undefined);
|
||||
}
|
||||
const buckets = groupPathsByRepo(paths);
|
||||
let last: GitOperationResult | undefined;
|
||||
for (const [repo, repoPaths] of buckets) {
|
||||
last = await op(repoPaths, repo || undefined);
|
||||
}
|
||||
return last as GitOperationResult;
|
||||
},
|
||||
[groupPathsByRepo],
|
||||
);
|
||||
|
||||
const stageFile = useCallback(
|
||||
async (paths: string[]) => {
|
||||
async (paths: string[], repo?: string) => {
|
||||
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
|
||||
try {
|
||||
return await gitOps.stage(paths);
|
||||
return await runPerRepo(paths, repo, (rp, r) => gitOps.stage(rp, r));
|
||||
} catch (err) {
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -145,14 +287,14 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
[gitOps.stage],
|
||||
[gitOps.stage, runPerRepo],
|
||||
);
|
||||
|
||||
const unstageFile = useCallback(
|
||||
async (paths: string[]) => {
|
||||
async (paths: string[], repo?: string) => {
|
||||
for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p));
|
||||
try {
|
||||
return await gitOps.unstage(paths);
|
||||
return await runPerRepo(paths, repo, (rp, r) => gitOps.unstage(rp, r));
|
||||
} catch (err) {
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -163,7 +305,19 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
[gitOps.unstage],
|
||||
[gitOps.unstage, runPerRepo],
|
||||
);
|
||||
|
||||
const discard = useCallback(
|
||||
async (paths?: string[], repo?: string) => {
|
||||
if (!paths || paths.length === 0) {
|
||||
// Discard with no paths is unsupported by the API; passthrough to surface the error.
|
||||
return gitOps.discard(paths, repo);
|
||||
}
|
||||
return runPerRepo(paths, repo, (rp, r) => gitOps.discard(rp, r));
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object
|
||||
[gitOps.discard, runPerRepo],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -199,14 +353,18 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
rebase: gitOps.rebase,
|
||||
merge: gitOps.merge,
|
||||
abort: gitOps.abort,
|
||||
commit: gitOps.commit,
|
||||
stage: gitOps.stage,
|
||||
commit,
|
||||
// stage/unstage route through the fan-out wrappers so multi-repo workspaces
|
||||
// hit the right repo subpath in agentctl. Empty/undefined paths = stage all.
|
||||
stage: (paths?: string[], repo?: string) =>
|
||||
paths && paths.length > 0 ? stageFile(paths, repo) : stageAll(),
|
||||
stageFile,
|
||||
stageAll,
|
||||
unstage: gitOps.unstage,
|
||||
unstage: (paths?: string[], repo?: string) =>
|
||||
paths && paths.length > 0 ? unstageFile(paths, repo) : unstageAll(),
|
||||
unstageFile,
|
||||
unstageAll,
|
||||
discard: gitOps.discard,
|
||||
discard,
|
||||
revertCommit: gitOps.revertCommit,
|
||||
renameBranch: gitOps.renameBranch,
|
||||
reset: gitOps.reset,
|
||||
|
||||
@@ -1,46 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { listRepositoryBranches } from "@/lib/api";
|
||||
import { listBranches } from "@/lib/api";
|
||||
import type { Branch } from "@/lib/types/http";
|
||||
|
||||
const EMPTY_BRANCHES: Branch[] = [];
|
||||
|
||||
export function useRepositoryBranches(repositoryId: string | null, enabled = true) {
|
||||
/**
|
||||
* Source of branches for a row: either a workspace repo (by id) or an
|
||||
* on-machine folder (by path). Both routes go through one backend endpoint
|
||||
* (`/workspaces/:id/branches`) and share one Zustand cache slice — id-based
|
||||
* entries are keyed by the repo id, path-based entries get a synthetic key.
|
||||
*
|
||||
* `workspaceId` is always required because the route segment needs it.
|
||||
*/
|
||||
export type BranchSource =
|
||||
| { kind: "id"; workspaceId: string; repositoryId: string }
|
||||
| { kind: "path"; workspaceId: string; path: string };
|
||||
|
||||
function cacheKeyFor(source: BranchSource | null): string {
|
||||
if (!source) return "";
|
||||
return source.kind === "id" ? source.repositoryId : `path::${source.workspaceId}::${source.path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads git branches for a workspace repo or an on-machine path. One hook,
|
||||
* one cache, one backend endpoint — the source shape decides which query
|
||||
* param goes on the wire and which key the cache uses.
|
||||
*/
|
||||
export function useBranches(
|
||||
source: BranchSource | null,
|
||||
enabled = true,
|
||||
): { branches: Branch[]; isLoading: boolean } {
|
||||
const key = cacheKeyFor(source);
|
||||
const branches = useAppStore((state) =>
|
||||
repositoryId
|
||||
? (state.repositoryBranches.itemsByRepositoryId[repositoryId] ?? EMPTY_BRANCHES)
|
||||
: EMPTY_BRANCHES,
|
||||
key ? (state.repositoryBranches.itemsByRepositoryId[key] ?? EMPTY_BRANCHES) : EMPTY_BRANCHES,
|
||||
);
|
||||
const isLoaded = useAppStore((state) =>
|
||||
repositoryId ? (state.repositoryBranches.loadedByRepositoryId[repositoryId] ?? false) : false,
|
||||
key ? (state.repositoryBranches.loadedByRepositoryId[key] ?? false) : false,
|
||||
);
|
||||
const isLoading = useAppStore((state) =>
|
||||
repositoryId ? (state.repositoryBranches.loadingByRepositoryId[repositoryId] ?? false) : false,
|
||||
key ? (state.repositoryBranches.loadingByRepositoryId[key] ?? false) : false,
|
||||
);
|
||||
const setRepositoryBranches = useAppStore((state) => state.setRepositoryBranches);
|
||||
const setRepositoryBranchesLoading = useAppStore((state) => state.setRepositoryBranchesLoading);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !repositoryId) return;
|
||||
if (!enabled || !source) return;
|
||||
if (isLoaded || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
setRepositoryBranchesLoading(repositoryId, true);
|
||||
// Capture the repositoryId at effect start for the closure
|
||||
const fetchRepoId = repositoryId;
|
||||
listRepositoryBranches(fetchRepoId, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
// Safe to always store: data is keyed by repositoryId in the store
|
||||
setRepositoryBranches(fetchRepoId, response.branches);
|
||||
})
|
||||
.catch(() => {
|
||||
setRepositoryBranches(fetchRepoId, []);
|
||||
})
|
||||
setRepositoryBranchesLoading(key, true);
|
||||
|
||||
const promise =
|
||||
source.kind === "id"
|
||||
? listBranches(source.workspaceId, { repositoryId: source.repositoryId })
|
||||
: listBranches(source.workspaceId, { path: source.path });
|
||||
|
||||
promise
|
||||
.then((response) => setRepositoryBranches(key, response.branches))
|
||||
.catch(() => setRepositoryBranches(key, []))
|
||||
.finally(() => {
|
||||
inFlightRef.current = false;
|
||||
setRepositoryBranchesLoading(fetchRepoId, false);
|
||||
setRepositoryBranchesLoading(key, false);
|
||||
});
|
||||
}, [enabled, isLoaded, repositoryId, setRepositoryBranches, setRepositoryBranchesLoading]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- key encodes source identity; listing every field re-fires on every render
|
||||
}, [enabled, isLoaded, key, setRepositoryBranches, setRepositoryBranchesLoading]);
|
||||
|
||||
return { branches, isLoading };
|
||||
}
|
||||
|
||||
@@ -21,19 +21,26 @@ export interface PRCreateResult {
|
||||
}
|
||||
|
||||
interface UseGitOperationsReturn {
|
||||
// Operation methods
|
||||
// Operation methods. The optional `repo` parameter is the multi-repo subpath
|
||||
// (e.g. "kandev"); pass empty/undefined for single-repo workspaces. Multi-repo
|
||||
// workspaces MUST scope each call to one repo — bulk callers fan out themselves.
|
||||
pull: (rebase?: boolean) => Promise<GitOperationResult>;
|
||||
push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise<GitOperationResult>;
|
||||
rebase: (baseBranch: string) => Promise<GitOperationResult>;
|
||||
merge: (baseBranch: string) => Promise<GitOperationResult>;
|
||||
abort: (operation: "merge" | "rebase") => Promise<GitOperationResult>;
|
||||
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
|
||||
stage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
unstage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
discard: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
|
||||
commit: (
|
||||
message: string,
|
||||
stageAll?: boolean,
|
||||
amend?: boolean,
|
||||
repo?: string,
|
||||
) => Promise<GitOperationResult>;
|
||||
stage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
unstage: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
discard: (paths?: string[], repo?: string) => Promise<GitOperationResult>;
|
||||
revertCommit: (commitSHA: string, repo?: string) => Promise<GitOperationResult>;
|
||||
renameBranch: (newName: string) => Promise<GitOperationResult>;
|
||||
reset: (commitSHA: string, mode: "soft" | "hard") => Promise<GitOperationResult>;
|
||||
reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise<GitOperationResult>;
|
||||
createPR: (
|
||||
title: string,
|
||||
body: string,
|
||||
@@ -72,30 +79,47 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) {
|
||||
const abort = async (operation: "merge" | "rebase") =>
|
||||
executeOperation<GitOperationResult>("worktree.abort", { operation });
|
||||
|
||||
const commit = async (message: string, stageAll = true, amend = false) =>
|
||||
const commit = async (message: string, stageAll = true, amend = false, repo?: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.commit", {
|
||||
message,
|
||||
stage_all: stageAll,
|
||||
amend,
|
||||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
const stage = async (paths?: string[]) =>
|
||||
executeOperation<GitOperationResult>("worktree.stage", { paths: paths ?? [] });
|
||||
const stage = async (paths?: string[], repo?: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.stage", {
|
||||
paths: paths ?? [],
|
||||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
const unstage = async (paths?: string[]) =>
|
||||
executeOperation<GitOperationResult>("worktree.unstage", { paths: paths ?? [] });
|
||||
const unstage = async (paths?: string[], repo?: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.unstage", {
|
||||
paths: paths ?? [],
|
||||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
const discard = async (paths?: string[]) =>
|
||||
executeOperation<GitOperationResult>("worktree.discard", { paths: paths ?? [] });
|
||||
const discard = async (paths?: string[], repo?: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.discard", {
|
||||
paths: paths ?? [],
|
||||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
const revertCommit = async (commitSHA: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.revert_commit", { commit_sha: commitSHA });
|
||||
const revertCommit = async (commitSHA: string, repo?: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.revert_commit", {
|
||||
commit_sha: commitSHA,
|
||||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
const renameBranch = async (newName: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.rename_branch", { new_name: newName });
|
||||
|
||||
const reset = async (commitSHA: string, mode: "soft" | "hard") =>
|
||||
executeOperation<GitOperationResult>("worktree.reset", { commit_sha: commitSHA, mode });
|
||||
const reset = async (commitSHA: string, mode: "soft" | "hard", repo?: string) =>
|
||||
executeOperation<GitOperationResult>("worktree.reset", {
|
||||
commit_sha: commitSHA,
|
||||
mode,
|
||||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
const createPR = async (
|
||||
title: string,
|
||||
|
||||
@@ -37,9 +37,22 @@ export async function listRepositories(
|
||||
return fetchJson<ListRepositoriesResponse>(url, options);
|
||||
}
|
||||
|
||||
export async function listRepositoryBranches(repositoryId: string, options?: ApiRequestOptions) {
|
||||
/**
|
||||
* Lists git branches for a workspace repo. Pass exactly one of `repositoryId`
|
||||
* (an imported workspace repo) or `path` (an on-machine folder discovered
|
||||
* but not yet imported). The backend resolves either to an absolute path and
|
||||
* runs the same `listGitBranches`.
|
||||
*/
|
||||
export async function listBranches(
|
||||
workspaceId: string,
|
||||
source: { repositoryId: string } | { path: string },
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if ("repositoryId" in source) params.set("repository_id", source.repositoryId);
|
||||
else params.set("path", source.path);
|
||||
return fetchJson<RepositoryBranchesResponse>(
|
||||
`/api/v1/repositories/${repositoryId}/branches`,
|
||||
`/api/v1/workspaces/${workspaceId}/branches?${params.toString()}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { groupByRepositoryName } from "./group-by-repo";
|
||||
|
||||
describe("groupByRepositoryName", () => {
|
||||
it("buckets by name in first-seen order, preserving item order within each bucket", () => {
|
||||
const items = [
|
||||
{ id: "a", repo: "frontend" },
|
||||
{ id: "b", repo: "backend" },
|
||||
{ id: "c", repo: "frontend" },
|
||||
{ id: "d", repo: "backend" },
|
||||
];
|
||||
const groups = groupByRepositoryName(items, (i) => i.repo);
|
||||
expect(groups.map((g) => g.repositoryName)).toEqual(["frontend", "backend"]);
|
||||
expect(groups[0].items.map((i) => i.id)).toEqual(["a", "c"]);
|
||||
expect(groups[1].items.map((i) => i.id)).toEqual(["b", "d"]);
|
||||
});
|
||||
|
||||
it("collapses missing/undefined names into a single empty-name bucket", () => {
|
||||
const items = [{ id: "x" }, { id: "y", repo: "named" }, { id: "z" }];
|
||||
const groups = groupByRepositoryName(items, (i) => (i as { repo?: string }).repo);
|
||||
expect(groups.map((g) => g.repositoryName)).toEqual(["", "named"]);
|
||||
expect(groups[0].items.map((i) => i.id)).toEqual(["x", "z"]);
|
||||
expect(groups[1].items.map((i) => i.id)).toEqual(["y"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty input", () => {
|
||||
expect(groupByRepositoryName([], () => "x")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Buckets a list into per-repository groups, preserving the original item
|
||||
* order within each bucket and producing groups in first-seen order. Items
|
||||
* whose `getName` returns undefined or empty land in a single empty-named
|
||||
* group so callers can decide whether to render a header for it.
|
||||
*
|
||||
* One copy of this lived in three places before — review-diff-list-groups,
|
||||
* changes-panel-timeline (files), and changes-panel-timeline (commits) —
|
||||
* differing only in element type. The generic shape collapses them all.
|
||||
*/
|
||||
export function groupByRepositoryName<T>(
|
||||
items: T[],
|
||||
getName: (item: T) => string | undefined,
|
||||
): Array<{ repositoryName: string; items: T[] }> {
|
||||
const order: string[] = [];
|
||||
const buckets = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const name = getName(item) ?? "";
|
||||
if (!buckets.has(name)) {
|
||||
buckets.set(name, []);
|
||||
order.push(name);
|
||||
}
|
||||
buckets.get(name)!.push(item);
|
||||
}
|
||||
return order.map((name) => ({ repositoryName: name, items: buckets.get(name)! }));
|
||||
}
|
||||
@@ -110,6 +110,8 @@ export type SessionCommit = {
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
created_at: string;
|
||||
/** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */
|
||||
repository_name?: string;
|
||||
};
|
||||
|
||||
export type CumulativeDiff = {
|
||||
|
||||
@@ -45,6 +45,8 @@ export type GitCommitData = {
|
||||
deletions: number;
|
||||
committed_at: string;
|
||||
created_at?: string;
|
||||
/** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */
|
||||
repository_name?: string;
|
||||
};
|
||||
|
||||
// Git reset data
|
||||
|
||||
@@ -61,6 +61,8 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
deletions: event.commit.deletions,
|
||||
committed_at: event.commit.committed_at,
|
||||
created_at: event.commit.created_at ?? event.timestamp,
|
||||
// Multi-repo: tag the commit so the Commits panel can group per repo.
|
||||
repository_name: event.commit.repository_name,
|
||||
});
|
||||
// Invalidate cumulative diff cache when new commit is created
|
||||
invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id));
|
||||
|
||||
Reference in New Issue
Block a user