diff --git a/Dockerfile b/Dockerfile index 8092dab1c8..fa73135933 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,17 +23,21 @@ LABEL org.opencontainers.image.pnpm-version="${PNPM_VERSION}" # Install runtime dependencies. gh is included because the GitHub integration # (PR review, webhooks) shells out to it for auth fallback when GITHUB_TOKEN -# is not set. apprise is installed via pipx for notification fan-out. +# is not set. Azure CLI + azure-devops extension support agentctl Azure Repos +# PR creation (az repos pr create). apprise is installed via pipx for +# notification fan-out. RUN apt-get update && \ apt-get install -y --no-install-recommends \ git \ gh \ ca-certificates \ + curl \ gosu \ tini \ python3 \ python3-venv \ pipx && \ + curl -sL https://aka.ms/InstallAzureCLIDeb | bash && \ rm -rf /var/lib/apt/lists/* && \ PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise @@ -42,6 +46,11 @@ RUN apt-get update && \ # copilot, amp, ...) lives on the PV and survives pod restarts and image upgrades. RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -d /data/home -M kandev +# Install azure-devops extension under the runtime user's Azure config dir on /data. +RUN mkdir -p /data/home/.azure && \ + AZURE_CONFIG_DIR=/data/home/.azure az extension add --name azure-devops && \ + chown -R kandev:kandev /data/home/.azure + # Create app directory structure matching what `kandev start` expects: # /app/apps/backend/bin/kandev # /app/apps/web/.next/standalone/web/server.js diff --git a/apps/backend/internal/agentctl/AGENTS.md b/apps/backend/internal/agentctl/AGENTS.md index bf7bc9fe11..5c93c0b632 100644 --- a/apps/backend/internal/agentctl/AGENTS.md +++ b/apps/backend/internal/agentctl/AGENTS.md @@ -20,10 +20,11 @@ agentctl exposes these route groups (see `server/api/`): | Remote host | CLI | Notes | |-------------|-----|-------| -| `dev.azure.com`, `ssh.dev.azure.com`, `*.visualstudio.com` | `az repos pr create` | Requires `az` on `PATH` and `azure-devops` extension. Auth: `az login` or `AZURE_DEVOPS_EXT_PAT`. | -| Everything else (today) | `gh pr create` | GitLab agentctl routing is not implemented yet — use the `/pr` skill `glab` flow. | +| `github.com`, `*.github.com` | `gh pr create` | Requires `gh` on `PATH` (included in the Kandev Docker image). | +| `dev.azure.com`, `ssh.dev.azure.com`, `*.visualstudio.com` | `az repos pr create` | Requires `az` on `PATH`, `azure-devops` extension (both in the Kandev Docker image). Auth: `az login` or `AZURE_DEVOPS_EXT_PAT`. | +| Other hosts (e.g. GitLab) | — | Returns an unsupported-remote error. Use host-specific CLIs outside agentctl (e.g. `/pr` skill `glab` for GitLab). | -Azure PR URLs are returned to the client but do not trigger backend `onPRCreated` / TaskPR linkage (GitHub-only today). +Azure PR URLs are returned to the client but do not trigger backend `onPRCreated` / TaskPR linkage (GitHub-only today). The web UI keeps a session-scoped pending PR URL so the changes panel hides **Create PR** after a successful Azure create. ## Adapter Model diff --git a/apps/backend/internal/agentctl/server/process/git_pr_providers.go b/apps/backend/internal/agentctl/server/process/git_pr_providers.go index f48655b12a..47dff7d9ea 100644 --- a/apps/backend/internal/agentctl/server/process/git_pr_providers.go +++ b/apps/backend/internal/agentctl/server/process/git_pr_providers.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/url" "os" @@ -17,14 +18,17 @@ import ( type prProvider string const ( - prProviderGitHub prProvider = "github" - prProviderAzureRepos prProvider = "azure_repos" - prCreateSubcommand = "create" - repositoryFlagTitle = "--title" - repositoryFlagBody = "--body" - repositoryFlagDescription = "--description" - repositoryFlagHead = "--head" - redactedLogValue = "[REDACTED]" + prProviderGitHub prProvider = "github" + prProviderAzureRepos prProvider = "azure_repos" + prCreateSubcommand = "create" + repositoryFlagTitle = "--title" + repositoryFlagBody = "--body" + repositoryFlagDescription = "--description" + repositoryFlagHead = "--head" + redactedLogValue = "[REDACTED]" + azureDevOpsExtensionName = "azure-devops" + errAzureCLIMissing = "azure CLI (az) is not on PATH; install it and run: az extension add --name azure-devops" + errAzureDevOpsExtensionMissing = "azure DevOps CLI extension is not installed; run: az extension add --name azure-devops" ) type azureRepoInfo struct { @@ -391,14 +395,27 @@ func (g *GitOperator) createGitHubPR( return result, nil } +func ensureAzureDevOpsCLI(ctx context.Context) error { + if _, err := exec.LookPath("az"); err != nil { + return errors.New(errAzureCLIMissing) + } + + cmd := exec.CommandContext(ctx, "az", "extension", "show", "--name", azureDevOpsExtensionName) + cmd.Env = filterGitEnv(os.Environ()) + if err := cmd.Run(); err != nil { + return errors.New(errAzureDevOpsExtensionMissing) + } + return nil +} + func (g *GitOperator) createAzureReposPR( ctx context.Context, result *PRCreateResult, remoteURL, branch, title, body, baseBranch string, draft bool, ) (*PRCreateResult, error) { - if _, err := exec.LookPath("az"); err != nil { - result.Error = "Azure CLI (az) is not on PATH; install it and run: az extension add --name azure-devops" + if err := ensureAzureDevOpsCLI(ctx); err != nil { + result.Error = err.Error() return result, nil } diff --git a/apps/backend/internal/agentctl/server/process/git_test.go b/apps/backend/internal/agentctl/server/process/git_test.go index 96c1842641..84f33a5a54 100644 --- a/apps/backend/internal/agentctl/server/process/git_test.go +++ b/apps/backend/internal/agentctl/server/process/git_test.go @@ -113,7 +113,17 @@ func TestGitOperatorCreatePR_UsesAzureCLIForAzureRepos(t *testing.T) { scriptDir := t.TempDir() azArgsPath := filepath.Join(scriptDir, "az-args.txt") writeExecutable(t, filepath.Join(scriptDir, "git"), fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%s\\n' 'git@ssh.dev.azure.com:v3/acme/platform/widgets'\n exit 0\nfi\nexec %q \"$@\"\n", "%s", realGit)) - writeExecutable(t, filepath.Join(scriptDir, "az"), fmt.Sprintf("#!/bin/sh\nprintf '%s\\n' \"$@\" > %q\nprintf '%s\\n' 'WARNING: preview command group' >&2\nprintf 'Creating pull request...\\n'\ncat <<'EOF'\n{\"pullRequestId\":42,\"repository\":{\"remoteUrl\":\"https://dev.azure.com/acme/platform/_git/widgets\"}}\nEOF\n", "%s", azArgsPath, "%s")) + writeExecutable(t, filepath.Join(scriptDir, "az"), fmt.Sprintf(`#!/bin/sh +if [ "$1" = "extension" ] && [ "$2" = "show" ]; then + exit 0 +fi +printf '%%s\n' "$@" > %q +printf 'WARNING: preview command group\n' >&2 +printf 'Creating pull request...\n' +cat <<'EOF' +{"pullRequestId":42,"repository":{"remoteUrl":"https://dev.azure.com/acme/platform/_git/widgets"}} +EOF +`, azArgsPath)) t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) gitOp := NewGitOperator(repoDir, log, nil) @@ -165,6 +175,135 @@ func writeExecutable(t *testing.T, path, content string) { } } +func writeGitRemoteWrapper(t *testing.T, scriptDir, originURL string) { + t.Helper() + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find git: %v", err) + } + writeExecutable( + t, + filepath.Join(scriptDir, "git"), + fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%%s\\n' %q\n exit 0\nfi\nexec %q \"$@\"\n", originURL, realGit), + ) +} + +func TestGitOperatorCreatePR_UnsupportedGitLabRemote(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell wrapper test is Unix-only") + } + + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + runGit(t, repoDir, "checkout", "-b", "feature/gitlab-pr") + writeFile(t, repoDir, "gitlab.txt", "gitlab\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "add gitlab change") + + scriptDir := t.TempDir() + writeGitRemoteWrapper(t, scriptDir, "git@gitlab.com:acme/widgets.git") + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + gitOp := NewGitOperator(repoDir, log, nil) + result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false) + if err != nil { + t.Fatalf("CreatePR returned error: %v", err) + } + if result.Success { + t.Fatalf("CreatePR should fail for GitLab remote: %+v", result) + } + if !strings.Contains(result.Error, "unsupported git remote") { + t.Fatalf("unexpected error: %q", result.Error) + } +} + +func TestGitOperatorCreatePR_AzureMissingCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell wrapper test is Unix-only") + } + + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-cli") + writeFile(t, repoDir, "azure.txt", "azure\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "add azure change") + + scriptDir := t.TempDir() + writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets") + // Isolate PATH so CI's preinstalled az is not visible to LookPath. + t.Setenv("PATH", scriptDir) + + gitOp := NewGitOperator(repoDir, log, nil) + result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false) + if err != nil { + t.Fatalf("CreatePR returned error: %v", err) + } + if result.Success { + t.Fatalf("CreatePR should fail without az: %+v", result) + } + if result.Error != errAzureCLIMissing { + t.Fatalf("error = %q, want %q", result.Error, errAzureCLIMissing) + } +} + +func TestGitOperatorCreatePR_AzureMissingExtension(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell wrapper test is Unix-only") + } + + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-ext") + writeFile(t, repoDir, "azure.txt", "azure\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "add azure change") + + scriptDir := t.TempDir() + writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets") + writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 1; fi\nexit 0\n") + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + gitOp := NewGitOperator(repoDir, log, nil) + result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false) + if err != nil { + t.Fatalf("CreatePR returned error: %v", err) + } + if result.Success { + t.Fatalf("CreatePR should fail without azure-devops extension: %+v", result) + } + if result.Error != errAzureDevOpsExtensionMissing { + t.Fatalf("error = %q, want %q", result.Error, errAzureDevOpsExtensionMissing) + } +} + +func TestEnsureAzureDevOpsCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell wrapper test is Unix-only") + } + + scriptDir := t.TempDir() + writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 0; fi\nexit 1\n") + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := ensureAzureDevOpsCLI(context.Background()); err != nil { + t.Fatalf("ensureAzureDevOpsCLI() error = %v", err) + } + + t.Setenv("PATH", "") + if err := ensureAzureDevOpsCLI(context.Background()); err == nil { + t.Fatal("expected error when az is missing") + } else if err.Error() != errAzureCLIMissing { + t.Fatalf("error = %q, want %q", err.Error(), errAzureCLIMissing) + } +} + func TestDetectPRProvider(t *testing.T) { tests := []struct { name string diff --git a/apps/web/components/task/changes-panel-body.tsx b/apps/web/components/task/changes-panel-body.tsx new file mode 100644 index 0000000000..54fafebdd6 --- /dev/null +++ b/apps/web/components/task/changes-panel-body.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { PanelBody } from "./panel-primitives"; +import { DiscardDialog, AmendDialog, ResetDialog } from "./changes-panel-dialogs"; +import { + FileListSection, + CommitsSection, + ReviewProgressBar, + PRFilesSection, +} from "./changes-panel-timeline"; +import { mergeCommits } from "./changes-panel-helpers"; +import type { ChangesPanelBodyProps } from "./changes-panel-data"; + +function ChangesPanelDialogsSection({ + dialogs, + isLoading, +}: Pick) { + return ( + <> + + + + + ); +} + +type TimelineProps = Pick< + ChangesPanelBodyProps, + | "hasAnything" + | "hasUnstaged" + | "hasStaged" + | "hasCommits" + | "hasPRFiles" + | "canPush" + | "canCreatePR" + | "existingPrUrl" + | "unstagedFiles" + | "stagedFiles" + | "prFiles" + | "prCommits" + | "commits" + | "pendingStageFiles" + | "aheadCount" + | "isLoading" + | "loadingOperation" + | "dialogs" + | "onOpenDiffFile" + | "onEditFile" + | "onOpenCommitDetail" + | "onRevertCommit" + | "onStageAll" + | "onUnstageAll" + | "onStage" + | "onUnstage" + | "onBulkStage" + | "onBulkUnstage" + | "onBulkDiscard" + | "onPush" + | "onForcePush" + | "onRepoStageAll" + | "onRepoUnstageAll" + | "onRepoCommit" + | "onRepoPush" + | "onRepoCreatePR" + | "repoDisplayName" + | "perRepoStatus" + | "prByRepo" +>; + +type WorkingTreeProps = Pick< + TimelineProps, + | "hasUnstaged" + | "hasStaged" + | "unstagedFiles" + | "stagedFiles" + | "pendingStageFiles" + | "loadingOperation" + | "dialogs" + | "onOpenDiffFile" + | "onEditFile" + | "onStageAll" + | "onUnstageAll" + | "onStage" + | "onUnstage" + | "onBulkStage" + | "onBulkUnstage" + | "onBulkDiscard" + | "onRepoStageAll" + | "onRepoUnstageAll" + | "onRepoCommit" + | "repoDisplayName" +> & { isLastUnstaged: boolean; isLastStaged: boolean }; + +function WorkingTreeSections(props: WorkingTreeProps) { + const isBulkOp = props.pendingStageFiles.size === 0; + return ( + <> + {props.hasUnstaged && ( + + )} + {props.hasStaged && ( + props.dialogs.openCommitDialog()} + secondaryActionLabel="Unstage all" + isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"} + onSecondaryAction={props.onUnstageAll} + onOpenDiff={props.onOpenDiffFile} + onEditFile={props.onEditFile} + onStage={props.onStage} + onUnstage={props.onUnstage} + onDiscard={props.dialogs.handleDiscardClick} + onBulkUnstage={props.onBulkUnstage} + onBulkDiscard={props.onBulkDiscard} + onRepoAction={props.onRepoCommit} + onRepoSecondaryAction={props.onRepoUnstageAll} + repoDisplayName={props.repoDisplayName} + /> + )} + + ); +} + +function ChangesPanelTimeline(props: TimelineProps) { + if (!props.hasAnything) { + return ( +
+ Your changed files will appear here +
+ ); + } + + const mergedCommits = mergeCommits(props.commits, props.prCommits); + const hasMergedCommits = mergedCommits.length > 0; + const hasLocalChanges = props.hasUnstaged || props.hasStaged; + const showCommits = props.hasStaged || props.hasCommits; + const showCommitsList = props.hasStaged || hasMergedCommits; + const hasSomethingAfterStaged = (props.hasPRFiles && hasLocalChanges) || showCommitsList; + + return ( +
+ {props.hasPRFiles && !hasLocalChanges && ( +
+ +
+ )} + + + + {props.hasPRFiles && hasLocalChanges && ( +
+ +
+ )} + + {showCommitsList && ( + + )} +
+ ); +} + +export function ChangesPanelBody(props: ChangesPanelBodyProps) { + return ( + +
+ +
+ + +
+ ); +} diff --git a/apps/web/components/task/changes-panel-data.tsx b/apps/web/components/task/changes-panel-data.tsx new file mode 100644 index 0000000000..cb79a99fda --- /dev/null +++ b/apps/web/components/task/changes-panel-data.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { useSessionGit } from "@/hooks/domains/session/use-session-git"; +import { useSessionFileReviews } from "@/hooks/use-session-file-reviews"; +import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; +import { useToast } from "@/components/toast-provider"; +import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; +import type { PRChangedFile } from "./changes-panel-timeline"; +import type { PRDiffFile } from "@/lib/types/github"; +import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; +import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files"; +import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; +import { + type ChangedFile, + computeReviewProgress, + computeStagedStats, + getBaseBranchDisplay, + mapPRFilesToChangedFiles, + mapToChangedFiles, + buildPrByRepoMap, + buildRepoNameById, +} from "./changes-panel-helpers"; +import type { OpenDiffOptions } from "./changes-diff-target"; + +function useChangesPanelStoreData() { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useEnvironmentSessionId(); + const taskTitle = useAppStore((state) => { + if (!state.tasks.activeTaskId) return undefined; + return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title; + }); + const baseBranch = useAppStore((state) => + activeSessionId ? state.taskSessions.items[activeSessionId]?.base_branch : undefined, + ); + const existingPrUrl = useAppStore((state) => { + const taskId = state.tasks.activeTaskId; + if (!taskId) return undefined; + const fromTaskPR = state.taskPRs.byTaskId[taskId]?.[0]?.pr_url; + if (fromTaskPR) return fromTaskPR; + return state.pendingPrUrlByTaskId.byTaskId[taskId]?.[""]; + }); + return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl }; +} + +type DialogsType = ReturnType & ReturnType; + +export type ChangesPanelBodyProps = { + hasAnything: boolean; + hasUnstaged: boolean; + hasStaged: boolean; + hasCommits: boolean; + hasPRFiles: boolean; + hasPRCommits: boolean; + canPush: boolean; + canCreatePR: boolean; + existingPrUrl: string | undefined; + unstagedFiles: ChangedFile[]; + stagedFiles: ChangedFile[]; + prFiles: PRChangedFile[]; + prCommits: { + sha: string; + message: string; + author_login: string; + author_date: string; + additions: number; + deletions: number; + }[]; + commits: { + commit_sha: string; + commit_message: string; + insertions: number; + deletions: number; + pushed?: boolean; + }[]; + pendingStageFiles: Set; + reviewedCount: number; + totalFileCount: number; + aheadCount: number; + isLoading: boolean; + loadingOperation: string | null; + dialogs: DialogsType; + onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void; + onEditFile: (path: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; + onOpenReview?: () => void; + onRevertCommit?: (sha: string, repo?: string) => void; + onStageAll: () => void; + onUnstageAll: () => void; + onStage: (path: string, repo?: string) => Promise; + onUnstage: (path: string, repo?: string) => Promise; + onBulkStage: (paths: string[]) => void; + onBulkUnstage: (paths: string[]) => void; + onBulkDiscard: (paths: string[]) => void; + onPush: () => void; + onForcePush: () => void; + stagedFileCount: number; + stagedAdditions: number; + stagedDeletions: number; + onRepoStageAll?: (repo: string) => void; + onRepoUnstageAll?: (repo: string) => void; + onRepoCommit?: (repo: string) => void; + onRepoPush?: (repo: string) => void; + onRepoCreatePR?: (repo: string) => void; + repoDisplayName?: (repositoryName: string) => string | undefined; + perRepoStatus?: Array<{ repository_name: string; ahead: number }>; + prByRepo?: Record; +}; + +function usePerRepoCallbacks( + git: ReturnType, + vcsDialogs: ReturnType, + gitHandlers: ReturnType, +) { + return useMemo( + () => ({ + onRepoStageAll: (repo: string) => { + gitHandlers.handleGitOperation( + () => git.stage(undefined, repo), + repo ? `Stage all (${repo})` : "Stage all", + ); + }, + onRepoUnstageAll: (repo: string) => { + gitHandlers.handleGitOperation( + () => git.unstage(undefined, repo), + repo ? `Unstage all (${repo})` : "Unstage all", + ); + }, + onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo), + onRepoPush: (repo: string) => gitHandlers.handlePush(repo), + onRepoCreatePR: (repo: string) => vcsDialogs.openPRDialog(repo), + onRepoPull: (repo: string) => gitHandlers.handlePull(repo), + onRepoRebase: (repo: string) => gitHandlers.handleRebase(repo), + onRepoMerge: (repo: string) => gitHandlers.handleMerge(repo), + }), + [git, vcsDialogs, gitHandlers], + ); +} + +function useChangesPanelPRData() { + const { prs, filesByPRKey } = useActiveTaskPRsWithFiles(); + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]); + const taskHasMultipleRepos = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return false; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return (task?.repositories?.length ?? 0) > 1; + }); + const taskPR = useActiveTaskPR(); + const refreshKey = taskPR?.last_synced_at ?? null; + const { commits: prCommitsList } = usePRCommits( + taskPR?.owner ?? null, + taskPR?.repo ?? null, + taskPR?.pr_number ?? null, + refreshKey, + ); + const { prFiles, prDiffFiles } = useMemo(() => { + const merged: PRChangedFile[] = []; + const flat: PRDiffFile[] = []; + for (const pr of prs) { + const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`; + const files = filesByPRKey[key] ?? []; + const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : ""; + const stamp = taskHasMultipleRepos ? repoName || `${pr.owner}/${pr.repo}` : ""; + merged.push(...mapPRFilesToChangedFiles(files, stamp)); + flat.push(...files); + } + return { prFiles: merged, prDiffFiles: flat }; + }, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]); + const hasPRFiles = prFiles.length > 0; + const hasPRCommits = prCommitsList.length > 0; + return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles }; +} + +export function useChangesPanelData() { + const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData(); + const baseBranchByRepo = useBaseBranchByRepo(activeTaskId); + const git = useSessionGit(activeSessionId); + const { toast } = useToast(); + const { reviews } = useSessionFileReviews(activeSessionId); + const prData = useChangesPanelPRData(); + const vcsDialogs = useVcsDialogs(); + const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]); + const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]); + const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]); + const { reviewedCount, totalFileCount } = useMemo( + () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles), + [git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles], + ); + const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]); + const gitHandlers = useChangesGitHandlers(git, toast, baseBranch); + const localDialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation); + const dialogs = { ...localDialogs, ...vcsDialogs }; + const repoCallbacks = usePerRepoCallbacks(git, vcsDialogs, gitHandlers); + const repoDisplayName = useRepoDisplayName(activeSessionId); + const taskPRsForMap = useAppStore((state) => + activeTaskId ? state.taskPRs.byTaskId[activeTaskId] : undefined, + ); + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]); + const pendingByRepo = useAppStore((state) => + activeTaskId ? state.pendingPrUrlByTaskId.byTaskId[activeTaskId] : undefined, + ); + const prByRepo = useMemo( + () => buildPrByRepoMap(taskPRsForMap, repoNameById, pendingByRepo), + [taskPRsForMap, repoNameById, pendingByRepo], + ); + return { + git, + baseBranchDisplay, + baseBranchByRepo, + unstagedFiles, + stagedFiles, + reviewedCount, + totalFileCount, + staged, + gitHandlers, + localDialogs, + dialogs, + repoCallbacks, + repoDisplayName, + prByRepo, + existingPrUrl, + ...prData, + }; +} + +type ChangesPanelCallbacks = { + onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void; + onEditFile: (path: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; + onOpenReview?: () => void; +}; + +export function buildChangesPanelBodyProps( + data: ReturnType, + callbacks: ChangesPanelCallbacks, +): ChangesPanelBodyProps { + const { git, gitHandlers, localDialogs, repoCallbacks, staged } = data; + return { + hasAnything: git.hasAnything || data.hasPRFiles || data.hasPRCommits, + hasUnstaged: git.hasUnstaged, + hasStaged: git.hasStaged, + hasCommits: git.hasCommits, + hasPRFiles: data.hasPRFiles, + hasPRCommits: data.hasPRCommits, + canPush: git.canPush, + canCreatePR: git.canCreatePR, + existingPrUrl: data.existingPrUrl, + unstagedFiles: data.unstagedFiles, + stagedFiles: data.stagedFiles, + prFiles: data.prFiles, + prCommits: data.prCommitsList, + commits: git.commits, + pendingStageFiles: git.pendingStageFiles, + reviewedCount: data.reviewedCount, + totalFileCount: data.totalFileCount, + aheadCount: git.ahead, + isLoading: git.isLoading, + loadingOperation: git.loadingOperation, + dialogs: data.dialogs, + onOpenDiffFile: callbacks.onOpenDiffFile, + onEditFile: callbacks.onEditFile, + onOpenCommitDetail: callbacks.onOpenCommitDetail, + onRevertCommit: gitHandlers.handleRevertCommit, + onOpenReview: callbacks.onOpenReview, + onStageAll: git.stageAll, + onUnstageAll: git.unstageAll, + onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined), + onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined), + onBulkStage: (paths) => { + git.stageFile(paths).catch(() => undefined); + }, + onBulkUnstage: (paths) => { + git.unstageFile(paths).catch(() => undefined); + }, + onBulkDiscard: localDialogs.handleBulkDiscardClick, + onPush: () => gitHandlers.handlePush(), + onForcePush: () => gitHandlers.handleForcePush(), + stagedFileCount: staged.stagedFileCount, + stagedAdditions: staged.stagedAdditions, + stagedDeletions: staged.stagedDeletions, + onRepoStageAll: repoCallbacks.onRepoStageAll, + onRepoUnstageAll: repoCallbacks.onRepoUnstageAll, + onRepoCommit: repoCallbacks.onRepoCommit, + onRepoPush: repoCallbacks.onRepoPush, + onRepoCreatePR: repoCallbacks.onRepoCreatePR, + repoDisplayName: data.repoDisplayName, + perRepoStatus: git.perRepoStatus, + prByRepo: data.prByRepo, + }; +} diff --git a/apps/web/components/task/changes-panel-helpers.test.ts b/apps/web/components/task/changes-panel-helpers.test.ts index 466a96b4a9..e10ace7d2f 100644 --- a/apps/web/components/task/changes-panel-helpers.test.ts +++ b/apps/web/components/task/changes-panel-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { mapPRFilesToChangedFiles } from "./changes-panel-helpers"; +import { buildPrByRepoMap, mapPRFilesToChangedFiles } from "./changes-panel-helpers"; import type { PRDiffFile } from "@/lib/types/github"; function diffFile(overrides: Partial): PRDiffFile { @@ -68,3 +68,28 @@ describe("mapPRFilesToChangedFiles", () => { expect(mapPRFilesToChangedFiles([], "frontend")).toEqual([]); }); }); + +describe("buildPrByRepoMap", () => { + it("does not copy a multi-repo TaskPR URL into the empty-key fallback", () => { + const map = buildPrByRepoMap( + [ + { pr_url: "https://github.com/o/r/pull/1", repository_id: "id-b" }, + { pr_url: "https://github.com/o/r/pull/2", repository_id: "id-a" }, + ], + { "id-a": "repo-a", "id-b": "repo-b" }, + undefined, + ); + expect(map[""]).toBeUndefined(); + expect(map["repo-a"]).toBe("https://github.com/o/r/pull/2"); + expect(map["repo-b"]).toBe("https://github.com/o/r/pull/1"); + }); + + it("uses empty-key fallback only for legacy TaskPR rows without repository_id", () => { + const map = buildPrByRepoMap( + [{ pr_url: "https://dev.azure.com/o/p/_git/r/pullrequest/9" }], + {}, + undefined, + ); + expect(map[""]).toBe("https://dev.azure.com/o/p/_git/r/pullrequest/9"); + }); +}); diff --git a/apps/web/components/task/changes-panel-helpers.ts b/apps/web/components/task/changes-panel-helpers.ts index f139fe2e11..05d7931f02 100644 --- a/apps/web/components/task/changes-panel-helpers.ts +++ b/apps/web/components/task/changes-panel-helpers.ts @@ -212,3 +212,47 @@ export function getBaseBranchDisplay(baseBranch: string | undefined): string { export function isMultiRepoCommits(commits: { repository_name?: string }[]): boolean { return commits.some((c) => !!c.repository_name); } + +type TaskPRUrlInput = { + pr_url?: string; + repository_id?: string; +}; + +/** Workspace repository id → display name for multi-repo PR/commit grouping. */ +export function buildRepoNameById( + reposByWorkspace: Record>, +): Record { + const out: Record = {}; + for (const list of Object.values(reposByWorkspace)) { + for (const r of list) { + out[r.id] = r.name; + } + } + return out; +} + +/** Merge synced TaskPR URLs and client-only pending PR URLs by repo display name. */ +export function buildPrByRepoMap( + taskPRs: TaskPRUrlInput[] | undefined, + repoNameById: Record, + pendingByRepo: Record | undefined, +): Record { + const map: Record = {}; + if (taskPRs) { + for (const pr of taskPRs) { + if (!pr.pr_url) continue; + const repoKey = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : ""; + map[repoKey] = map[repoKey] ?? pr.pr_url; + } + } + if (pendingByRepo) { + for (const [repoKey, url] of Object.entries(pendingByRepo)) { + if (url) map[repoKey] = map[repoKey] ?? url; + } + } + const legacySingleRepo = taskPRs?.find((p) => p.pr_url && !p.repository_id); + if (!map[""] && legacySingleRepo?.pr_url) { + map[""] = legacySingleRepo.pr_url; + } + return map; +} diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 4622a6dde3..05749326d0 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -1,40 +1,12 @@ "use client"; -import { memo, useMemo } from "react"; -import { PanelRoot, PanelBody } from "./panel-primitives"; -import { useAppStore } from "@/components/state-provider"; -import { useSessionGit } from "@/hooks/domains/session/use-session-git"; -import { useSessionFileReviews } from "@/hooks/use-session-file-reviews"; -import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; -import { useToast } from "@/components/toast-provider"; +import { memo } from "react"; +import { PanelRoot } from "./panel-primitives"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; -import { DiscardDialog, AmendDialog, ResetDialog } from "./changes-panel-dialogs"; -import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; import { ChangesPanelHeader } from "./changes-panel-header"; -import { - FileListSection, - CommitsSection, - ReviewProgressBar, - PRFilesSection, -} from "./changes-panel-timeline"; -import type { PRChangedFile } from "./changes-panel-timeline"; -import type { PRDiffFile } from "@/lib/types/github"; -import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; -import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; -import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo"; -import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; -import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files"; -import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; -import { - type ChangedFile, - computeReviewProgress, - computeStagedStats, - filterUnpushedCommits, - getBaseBranchDisplay, - mapPRFilesToChangedFiles, - mapToChangedFiles, - mergeCommits, -} from "./changes-panel-helpers"; +import { filterUnpushedCommits, mergeCommits } from "./changes-panel-helpers"; +import { useChangesPanelData, buildChangesPanelBodyProps } from "./changes-panel-data"; +import { ChangesPanelBody } from "./changes-panel-body"; import type { OpenDiffOptions } from "./changes-diff-target"; export { filterUnpushedCommits, mergeCommits }; @@ -47,546 +19,6 @@ type ChangesPanelProps = { onOpenReview?: () => void; }; -function useChangesPanelStoreData() { - const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - // Use environment-stable sessionId so git hooks (commits, cumulative diff) - // don't re-fetch when switching between sessions in the same environment. - const activeSessionId = useEnvironmentSessionId(); - const taskTitle = useAppStore((state) => { - if (!state.tasks.activeTaskId) return undefined; - return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title; - }); - const baseBranch = useAppStore((state) => - activeSessionId ? state.taskSessions.items[activeSessionId]?.base_branch : undefined, - ); - const existingPrUrl = useAppStore((state) => { - const taskId = state.tasks.activeTaskId; - if (!taskId) return undefined; - // Single-PR consumer: surface the primary PR's URL. Multi-repo cases get - // per-repo PR URLs via the timeline's prByRepo map elsewhere. - return state.taskPRs.byTaskId[taskId]?.[0]?.pr_url ?? undefined; - }); - return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl }; -} - -type DialogsType = ReturnType & ReturnType; - -type ChangesPanelBodyProps = { - hasAnything: boolean; - hasUnstaged: boolean; - hasStaged: boolean; - hasCommits: boolean; - hasPRFiles: boolean; - hasPRCommits: boolean; - canPush: boolean; - canCreatePR: boolean; - existingPrUrl: string | undefined; - unstagedFiles: ChangedFile[]; - stagedFiles: ChangedFile[]; - prFiles: PRChangedFile[]; - prCommits: { - sha: string; - message: string; - author_login: string; - author_date: string; - additions: number; - deletions: number; - }[]; - commits: { - commit_sha: string; - commit_message: string; - insertions: number; - deletions: number; - pushed?: boolean; - }[]; - pendingStageFiles: Set; - reviewedCount: number; - totalFileCount: number; - aheadCount: number; - isLoading: boolean; - loadingOperation: string | null; - dialogs: DialogsType; - onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void; - onEditFile: (path: string) => void; - onOpenCommitDetail?: (sha: string, repo?: string) => void; - onOpenReview?: () => 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; - // 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; - onUnstage: (path: string, repo?: string) => Promise; - onBulkStage: (paths: string[]) => void; - onBulkUnstage: (paths: string[]) => void; - onBulkDiscard: (paths: string[]) => void; - onPush: () => void; - onForcePush: () => void; - stagedFileCount: number; - stagedAdditions: number; - stagedDeletions: number; - // Per-repo handlers — wired only in multi-repo workspaces. Each one targets - // a single repo subpath in agentctl. Single-repo sessions don't render the - // per-repo group header, so these are unused in that case. - onRepoStageAll?: (repo: string) => void; - onRepoUnstageAll?: (repo: string) => void; - onRepoCommit?: (repo: string) => void; - onRepoPush?: (repo: string) => void; - onRepoCreatePR?: (repo: string) => void; - /** Maps a repository_name to its display label (used as the per-repo group label). */ - repoDisplayName?: (repositoryName: string) => string | undefined; - /** Per-repo branch / ahead / behind summary; powers the "ahead" badge on Push. */ - perRepoStatus?: Array<{ repository_name: string; ahead: number }>; - /** Existing PR URL keyed by repository_name; "" key for single-repo. */ - prByRepo?: Record; -}; - -function ChangesPanelDialogsSection({ - dialogs, - isLoading, -}: Pick) { - return ( - <> - - - - - ); -} - -type TimelineProps = Pick< - ChangesPanelBodyProps, - | "hasAnything" - | "hasUnstaged" - | "hasStaged" - | "hasCommits" - | "hasPRFiles" - | "canPush" - | "canCreatePR" - | "existingPrUrl" - | "unstagedFiles" - | "stagedFiles" - | "prFiles" - | "prCommits" - | "commits" - | "pendingStageFiles" - | "aheadCount" - | "isLoading" - | "loadingOperation" - | "dialogs" - | "onOpenDiffFile" - | "onEditFile" - | "onOpenCommitDetail" - | "onRevertCommit" - | "onStageAll" - | "onUnstageAll" - | "onStage" - | "onUnstage" - | "onBulkStage" - | "onBulkUnstage" - | "onBulkDiscard" - | "onPush" - | "onForcePush" - | "onRepoStageAll" - | "onRepoUnstageAll" - | "onRepoCommit" - | "onRepoPush" - | "onRepoCreatePR" - | "repoDisplayName" - | "perRepoStatus" - | "prByRepo" ->; - -type WorkingTreeProps = Pick< - TimelineProps, - | "hasUnstaged" - | "hasStaged" - | "unstagedFiles" - | "stagedFiles" - | "pendingStageFiles" - | "loadingOperation" - | "dialogs" - | "onOpenDiffFile" - | "onEditFile" - | "onStageAll" - | "onUnstageAll" - | "onStage" - | "onUnstage" - | "onBulkStage" - | "onBulkUnstage" - | "onBulkDiscard" - | "onRepoStageAll" - | "onRepoUnstageAll" - | "onRepoCommit" - | "repoDisplayName" -> & { isLastUnstaged: boolean; isLastStaged: boolean }; - -function WorkingTreeSections(props: WorkingTreeProps) { - const isBulkOp = props.pendingStageFiles.size === 0; - return ( - <> - {props.hasUnstaged && ( - - )} - {props.hasStaged && ( - props.dialogs.openCommitDialog()} - secondaryActionLabel="Unstage all" - isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"} - onSecondaryAction={props.onUnstageAll} - onOpenDiff={props.onOpenDiffFile} - onEditFile={props.onEditFile} - onStage={props.onStage} - onUnstage={props.onUnstage} - onDiscard={props.dialogs.handleDiscardClick} - onBulkUnstage={props.onBulkUnstage} - onBulkDiscard={props.onBulkDiscard} - onRepoAction={props.onRepoCommit} - onRepoSecondaryAction={props.onRepoUnstageAll} - repoDisplayName={props.repoDisplayName} - /> - )} - - ); -} - -function ChangesPanelTimeline(props: TimelineProps) { - if (!props.hasAnything) { - return ( -
- Your changed files will appear here -
- ); - } - - const mergedCommits = mergeCommits(props.commits, props.prCommits); - const hasMergedCommits = mergedCommits.length > 0; - const hasLocalChanges = props.hasUnstaged || props.hasStaged; - const showCommits = props.hasStaged || props.hasCommits; - const showCommitsList = props.hasStaged || hasMergedCommits; - const hasSomethingAfterStaged = (props.hasPRFiles && hasLocalChanges) || showCommitsList; - - return ( -
- {/* PR files first when no local working-tree changes */} - {props.hasPRFiles && !hasLocalChanges && ( -
- -
- )} - - - - {/* PR files after local changes when both exist */} - {props.hasPRFiles && hasLocalChanges && ( -
- -
- )} - - {showCommitsList && ( - - )} -
- ); -} - -function ChangesPanelBody(props: ChangesPanelBodyProps) { - return ( - -
- -
- - -
- ); -} - -/** - * Adapts the existing single-repo handlers to the per-repo invocation sites - * (group-header buttons). Each callback simply forwards `repo` to the - * underlying op — the routing happens inside `useSessionGit` / - * `useGitOperations`. Single-repo passes the empty string, which the backend - * treats as the workspace root. - */ -function usePerRepoCallbacks( - git: ReturnType, - vcsDialogs: ReturnType, - gitHandlers: ReturnType, -) { - // Bug 9: route per-repo stage/unstage through `handleGitOperation` so toasts - // fire for both success and failure. The previous `.catch(() => undefined)` - // silenced rejections — the user got no feedback when an op failed. - return useMemo( - () => ({ - onRepoStageAll: (repo: string) => { - gitHandlers.handleGitOperation( - () => git.stage(undefined, repo), - repo ? `Stage all (${repo})` : "Stage all", - ); - }, - onRepoUnstageAll: (repo: string) => { - gitHandlers.handleGitOperation( - () => git.unstage(undefined, repo), - repo ? `Unstage all (${repo})` : "Unstage all", - ); - }, - onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo), - onRepoPush: (repo: string) => gitHandlers.handlePush(repo), - onRepoCreatePR: (repo: string) => vcsDialogs.openPRDialog(repo), - onRepoPull: (repo: string) => gitHandlers.handlePull(repo), - onRepoRebase: (repo: string) => gitHandlers.handleRebase(repo), - onRepoMerge: (repo: string) => gitHandlers.handleMerge(repo), - }), - [git, vcsDialogs, gitHandlers], - ); -} - -function useChangesPanelPRData() { - // Multi-repo tasks have one TaskPR per repo; fan out file fetches per PR - // and stamp each file with its repository_name so PRFilesSection can - // group by repo. Commits stay scoped to the primary PR for now — the - // commits section already groups by repo from the workspace git status, - // not from the PR itself. - const { prs, filesByPRKey } = useActiveTaskPRsWithFiles(); - // Subscribe to the slice (stable ref between hydrations), then build the - // id -> name lookup with useMemo so the dependency in the next useMemo is - // stable and we don't re-create the merged file list on every render. - const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); - const repoNameById = useMemo>(() => { - const out: Record = {}; - for (const list of Object.values(reposByWorkspace)) { - for (const r of list as Array<{ id: string; name: string }>) { - out[r.id] = r.name; - } - } - return out; - }, [reposByWorkspace]); - // Tag PR files with their repo whenever the TASK is multi-repo, not just - // when there are 2+ PRs. A multi-repo task with one PR (e.g. one repo - // doesn't have a branch yet) still benefits from the per-repo header so - // the user can tell which repo's files are being shown — the COMMITS and - // STAGED sections already do this. - const taskHasMultipleRepos = useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return false; - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return (task?.repositories?.length ?? 0) > 1; - }); - const taskPR = useActiveTaskPR(); - const refreshKey = taskPR?.last_synced_at ?? null; - const { commits: prCommitsList } = usePRCommits( - taskPR?.owner ?? null, - taskPR?.repo ?? null, - taskPR?.pr_number ?? null, - refreshKey, - ); - const { prFiles, prDiffFiles } = useMemo(() => { - const merged: PRChangedFile[] = []; - const flat: PRDiffFile[] = []; - for (const pr of prs) { - const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`; - const files = filesByPRKey[key] ?? []; - const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : ""; - // Single-repo tasks render flat (the section header is enough); - // multi-repo tasks always tag with the resolved repo name so the - // PRFilesGroupedList renders one sub-header per repo, even when only - // one repo has a PR yet. Falls back to owner/repo if the workspace - // lookup misses, so the row never lands in an untagged phantom group. - const stamp = taskHasMultipleRepos ? repoName || `${pr.owner}/${pr.repo}` : ""; - merged.push(...mapPRFilesToChangedFiles(files, stamp)); - flat.push(...files); - } - return { prFiles: merged, prDiffFiles: flat }; - }, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]); - const hasPRFiles = prFiles.length > 0; - const hasPRCommits = prCommitsList.length > 0; - return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles }; -} - -function useChangesPanelData() { - const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData(); - const baseBranchByRepo = useBaseBranchByRepo(activeTaskId); - const git = useSessionGit(activeSessionId); - const { toast } = useToast(); - const { reviews } = useSessionFileReviews(activeSessionId); - const prData = useChangesPanelPRData(); - const vcsDialogs = useVcsDialogs(); - const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]); - const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]); - const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]); - const { reviewedCount, totalFileCount } = useMemo( - () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles), - [git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles], - ); - const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]); - const gitHandlers = useChangesGitHandlers(git, toast, baseBranch); - const localDialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation); - const dialogs = { ...localDialogs, ...vcsDialogs }; - const repoCallbacks = usePerRepoCallbacks(git, vcsDialogs, gitHandlers); - const repoDisplayName = useRepoDisplayName(activeSessionId); - // Existing PR URL is currently workspace-scoped — surface it under the - // empty (single-repo) key so per-repo Create PR can show "PR exists". - const prByRepo = useMemo>( - () => ({ "": existingPrUrl }), - [existingPrUrl], - ); - return { - git, - baseBranchDisplay, - baseBranchByRepo, - unstagedFiles, - stagedFiles, - reviewedCount, - totalFileCount, - staged, - gitHandlers, - localDialogs, - dialogs, - repoCallbacks, - repoDisplayName, - prByRepo, - existingPrUrl, - ...prData, - }; -} - -function buildChangesPanelBodyProps( - data: ReturnType, - callbacks: ChangesPanelProps, -): ChangesPanelBodyProps { - const { git, gitHandlers, localDialogs, repoCallbacks, staged } = data; - return { - hasAnything: git.hasAnything || data.hasPRFiles || data.hasPRCommits, - hasUnstaged: git.hasUnstaged, - hasStaged: git.hasStaged, - hasCommits: git.hasCommits, - hasPRFiles: data.hasPRFiles, - hasPRCommits: data.hasPRCommits, - canPush: git.canPush, - canCreatePR: git.canCreatePR, - existingPrUrl: data.existingPrUrl, - unstagedFiles: data.unstagedFiles, - stagedFiles: data.stagedFiles, - prFiles: data.prFiles, - prCommits: data.prCommitsList, - commits: git.commits, - pendingStageFiles: git.pendingStageFiles, - reviewedCount: data.reviewedCount, - totalFileCount: data.totalFileCount, - aheadCount: git.ahead, - isLoading: git.isLoading, - loadingOperation: git.loadingOperation, - dialogs: data.dialogs, - onOpenDiffFile: callbacks.onOpenDiffFile, - onEditFile: callbacks.onEditFile, - onOpenCommitDetail: callbacks.onOpenCommitDetail, - onRevertCommit: gitHandlers.handleRevertCommit, - onOpenReview: callbacks.onOpenReview, - onStageAll: git.stageAll, - onUnstageAll: git.unstageAll, - onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined), - onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined), - onBulkStage: (paths) => { - git.stageFile(paths).catch(() => undefined); - }, - onBulkUnstage: (paths) => { - git.unstageFile(paths).catch(() => undefined); - }, - onBulkDiscard: localDialogs.handleBulkDiscardClick, - onPush: () => gitHandlers.handlePush(), - onForcePush: () => gitHandlers.handleForcePush(), - stagedFileCount: staged.stagedFileCount, - stagedAdditions: staged.stagedAdditions, - stagedDeletions: staged.stagedDeletions, - onRepoStageAll: repoCallbacks.onRepoStageAll, - onRepoUnstageAll: repoCallbacks.onRepoUnstageAll, - onRepoCommit: repoCallbacks.onRepoCommit, - onRepoPush: repoCallbacks.onRepoPush, - onRepoCreatePR: repoCallbacks.onRepoCreatePR, - repoDisplayName: data.repoDisplayName, - perRepoStatus: git.perRepoStatus, - prByRepo: data.prByRepo, - }; -} - const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) { const isArchived = useIsTaskArchived(); const data = useChangesPanelData(); @@ -618,4 +50,4 @@ const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) { }); export { ChangesPanel, ChangesPanelBody, useChangesPanelData, buildChangesPanelBodyProps }; -export type { ChangesPanelBodyProps }; +export type { ChangesPanelBodyProps } from "./changes-panel-data"; diff --git a/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx b/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx index b9290fdabe..60eaf894a6 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx @@ -40,6 +40,7 @@ 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 { useAppStore } from "@/components/state-provider"; import { CommitSummary, MobilePRBranchSummary, @@ -434,6 +435,8 @@ export function useMobileGitActions( setPrDialogOpen: (v: boolean) => void, ) { const { toast } = useToast(); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const setPendingPrUrlForTask = useAppStore((state) => state.setPendingPrUrlForTask); // 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. @@ -484,7 +487,12 @@ export function useMobileGitActions( description: result.pr_url || "PR created successfully", variant: "success", }); - if (result.pr_url) window.open(result.pr_url, "_blank"); + if (result.pr_url) { + if (activeTaskId) { + setPendingPrUrlForTask(activeTaskId, "", result.pr_url); + } + window.open(result.pr_url, "_blank"); + } } else { toast({ title: "Create PR failed", @@ -500,7 +508,7 @@ export function useMobileGitActions( }); } }, - [createPR, baseBranch, toast, setPrDialogOpen], + [createPR, baseBranch, toast, setPrDialogOpen, activeTaskId, setPendingPrUrlForTask], ); return { diff --git a/apps/web/components/vcs/vcs-dialogs.tsx b/apps/web/components/vcs/vcs-dialogs.tsx index 2944bac96e..47d7a893a0 100644 --- a/apps/web/components/vcs/vcs-dialogs.tsx +++ b/apps/web/components/vcs/vcs-dialogs.tsx @@ -28,6 +28,7 @@ import { import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; import { useGitOperations } from "@/hooks/use-git-operations"; +import { useAppStore } from "@/components/state-provider"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; @@ -469,6 +470,8 @@ function useCreatePRHandler( createPR: ReturnType["createPR"], toast: ReturnType["toast"], ) { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const setPendingPrUrlForTask = useAppStore((state) => state.setPendingPrUrlForTask); return useCallback(async () => { if (!ps.title.trim()) return; ps.setOpen(false); @@ -487,7 +490,12 @@ function useCreatePRHandler( description: result.pr_url || "PR created successfully", variant: "success", }); - if (result.pr_url) window.open(result.pr_url, "_blank"); + if (result.pr_url) { + if (activeTaskId) { + setPendingPrUrlForTask(activeTaskId, ps.repo || "", result.pr_url); + } + window.open(result.pr_url, "_blank"); + } } else { toast({ title: "Create PR failed", @@ -504,7 +512,7 @@ function useCreatePRHandler( } ps.setTitle(""); ps.setBody(""); - }, [ps, baseBranch, createPR, toast]); + }, [ps, baseBranch, createPR, toast, activeTaskId, setPendingPrUrlForTask]); } function useVcsDialogsState( diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 9d2c13170c..5a6f303c82 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -65,6 +65,7 @@ export const defaultState = { sessionPollMode: defaultSessionRuntimeState.sessionPollMode, githubStatus: defaultGitHubState.githubStatus, taskPRs: defaultGitHubState.taskPRs, + pendingPrUrlByTaskId: defaultGitHubState.pendingPrUrlByTaskId, prWatches: defaultGitHubState.prWatches, reviewWatches: defaultGitHubState.reviewWatches, issueWatches: defaultGitHubState.issueWatches, diff --git a/apps/web/lib/state/slices/github/github-slice.test.ts b/apps/web/lib/state/slices/github/github-slice.test.ts index 23441a29ed..69a125465d 100644 --- a/apps/web/lib/state/slices/github/github-slice.test.ts +++ b/apps/web/lib/state/slices/github/github-slice.test.ts @@ -180,3 +180,32 @@ describe("setTaskPR", () => { expect(store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]); }); }); + +describe("setPendingPrUrlForTask", () => { + it("stores a pending PR URL until TaskPR sync clears it", () => { + const store = makeStore(); + + store + .getState() + .setPendingPrUrlForTask("task-1", "", "https://dev.azure.com/o/p/_git/r/pullrequest/1"); + expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.[""]).toBe( + "https://dev.azure.com/o/p/_git/r/pullrequest/1", + ); + + store.getState().setTaskPR("task-1", makePR()); + expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]).toBeUndefined(); + }); + + it("clears only the synced repo pending URL in multi-repo tasks", () => { + const store = makeStore(); + const urlA = "https://dev.azure.com/o/p/_git/a/pullrequest/1"; + const urlB = "https://dev.azure.com/o/p/_git/b/pullrequest/2"; + + store.getState().setPendingPrUrlForTask("task-1", "repo-a", urlA); + store.getState().setPendingPrUrlForTask("task-1", "repo-b", urlB); + store.getState().setTaskPR("task-1", makePR({ repository_id: "repo-a", pr_url: urlA })); + + expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-b"]).toBe(urlB); + expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-a"]).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/state/slices/github/github-slice.ts b/apps/web/lib/state/slices/github/github-slice.ts index 6063b6c7d5..ae0bd59b2a 100644 --- a/apps/web/lib/state/slices/github/github-slice.ts +++ b/apps/web/lib/state/slices/github/github-slice.ts @@ -4,6 +4,7 @@ import type { GitHubSlice, GitHubSliceState } from "./types"; export const defaultGitHubState: GitHubSliceState = { githubStatus: { status: null, loaded: false, loading: false }, taskPRs: { byTaskId: {} }, + pendingPrUrlByTaskId: { byTaskId: {} }, prWatches: { items: [], loaded: false, loading: false }, reviewWatches: { items: [], loaded: false, loading: false }, issueWatches: { items: [], loaded: false, loading: false }, @@ -33,7 +34,33 @@ function createGitHubStatusActions( }; } -function createTaskPRActions(set: ImmerSet): Pick { +function clearPendingPrUrlForRepo(draft: GitHubSlice, taskId: string, repoKey: string) { + const pending = draft.pendingPrUrlByTaskId.byTaskId[taskId]; + if (!pending) return; + delete pending[repoKey]; + if (Object.keys(pending).length === 0) { + delete draft.pendingPrUrlByTaskId.byTaskId[taskId]; + } +} + +/** Clear client-only pending URLs for the repo that just synced (not sibling repos). */ +function clearPendingForTaskPR( + draft: GitHubSlice, + taskId: string, + pr: { repository_id?: string; pr_url?: string }, +) { + clearPendingPrUrlForRepo(draft, taskId, pr.repository_id ?? ""); + clearPendingPrUrlForRepo(draft, taskId, ""); + const pending = draft.pendingPrUrlByTaskId.byTaskId[taskId]; + if (!pending || !pr.pr_url) return; + for (const key of Object.keys(pending)) { + if (pending[key] === pr.pr_url) clearPendingPrUrlForRepo(draft, taskId, key); + } +} + +function createTaskPRActions( + set: ImmerSet, +): Pick { return { setTaskPRs: (prs) => set((draft) => { @@ -51,6 +78,19 @@ function createTaskPRActions(set: ImmerSet): Pick= 0) existing[idx] = pr; else existing.push(pr); draft.taskPRs.byTaskId[taskId] = existing; + clearPendingForTaskPR(draft, taskId, pr); + }), + setPendingPrUrlForTask: (taskId, repoKey, prUrl) => + set((draft) => { + const trimmed = prUrl.trim(); + if (!trimmed) { + clearPendingPrUrlForRepo(draft, taskId, repoKey); + return; + } + if (!draft.pendingPrUrlByTaskId.byTaskId[taskId]) { + draft.pendingPrUrlByTaskId.byTaskId[taskId] = {}; + } + draft.pendingPrUrlByTaskId.byTaskId[taskId][repoKey] = trimmed; }), }; } diff --git a/apps/web/lib/state/slices/github/types.ts b/apps/web/lib/state/slices/github/types.ts index 0663822aaa..b8d2f90b3d 100644 --- a/apps/web/lib/state/slices/github/types.ts +++ b/apps/web/lib/state/slices/github/types.ts @@ -20,6 +20,14 @@ export type TaskPRsState = { byTaskId: Record; }; +export type PendingPrUrlsState = { + /** + * Client-only PR URLs after Create PR succeeds before TaskPR sync (e.g. Azure Repos). + * Keyed by task id, then repo name (or "" for single-repo). + */ + byTaskId: Record>; +}; + export type PRWatchesState = { items: PRWatch[]; loaded: boolean; @@ -56,6 +64,7 @@ export type PRFeedbackCacheState = { export type GitHubSliceState = { githubStatus: GitHubStatusState; taskPRs: TaskPRsState; + pendingPrUrlByTaskId: PendingPrUrlsState; prWatches: PRWatchesState; reviewWatches: ReviewWatchesState; issueWatches: IssueWatchesState; @@ -68,6 +77,7 @@ export type GitHubSliceActions = { setGitHubStatusLoading: (loading: boolean) => void; setTaskPRs: (prs: Record) => void; setTaskPR: (taskId: string, pr: TaskPR) => void; + setPendingPrUrlForTask: (taskId: string, repoKey: string, prUrl: string) => void; setPRWatches: (watches: PRWatch[]) => void; setPRWatchesLoading: (loading: boolean) => void; removePRWatch: (id: string) => void; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 7f9e3e076c..b73c9b1afd 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -9,16 +9,6 @@ import type { Turn, TaskSession, } from "@/lib/types/http"; -import type { - GitHubStatus, - GitHubRateLimitUpdate, - TaskPR, - PRWatch, - ReviewWatch as GitHubReviewWatch, - IssueWatch as GitHubIssueWatch, - GitHubActionPresets, - PRFeedback, -} from "@/lib/types/github"; import type { SystemHealthResponse } from "@/lib/types/health"; import type { UISliceActions as UIA } from "./slices/ui/types"; import type * as UISliceTypes from "./slices/ui/types"; @@ -77,6 +67,8 @@ import { type ConnectionState, type SystemSliceActions, type AutomationsSliceActions, + type FeaturesSliceActions, + type GitHubSliceActions, } from "./slices"; import type { AvailableCommand, @@ -90,8 +82,7 @@ import type { UserShellInfo, } from "./slices/session-runtime/types"; -// Re-export all types from slices for backwards compatibility (split out -// to keep this file under the max-lines limit). +// Re-export all types from slices for backwards compatibility. export type * from "./store-reexports"; import type { TaskMR } from "@/lib/types/gitlab"; import type { JiraIssueWatch } from "@/lib/types/jira"; @@ -186,6 +177,7 @@ export type AppState = { // GitHub slice githubStatus: (typeof defaultGitHubState)["githubStatus"]; taskPRs: (typeof defaultGitHubState)["taskPRs"]; + pendingPrUrlByTaskId: (typeof defaultGitHubState)["pendingPrUrlByTaskId"]; prWatches: (typeof defaultGitHubState)["prWatches"]; reviewWatches: (typeof defaultGitHubState)["reviewWatches"]; issueWatches: (typeof defaultGitHubState)["issueWatches"]; @@ -206,7 +198,6 @@ export type AppState = { // Feature flags slice features: (typeof defaultFeaturesState)["features"]; - setFeatures: (features: (typeof defaultFeaturesState)["features"]) => void; // Automations slice automations: (typeof defaultAutomationsState)["automations"]; @@ -234,30 +225,6 @@ export type AppState = { kanbanPreviewedTaskId: (typeof defaultUIState)["kanbanPreviewedTaskId"]; sidebarTaskPrefs: (typeof defaultUIState)["sidebarTaskPrefs"]; - // GitHub actions - setGitHubStatus: (status: GitHubStatus | null) => void; - setGitHubStatusLoading: (loading: boolean) => void; - setTaskPRs: (prs: Record) => void; - setTaskPR: (taskId: string, pr: TaskPR) => void; - setPRWatches: (watches: PRWatch[]) => void; - setPRWatchesLoading: (loading: boolean) => void; - removePRWatch: (id: string) => void; - setReviewWatches: (watches: GitHubReviewWatch[]) => void; - setReviewWatchesLoading: (loading: boolean) => void; - addReviewWatch: (watch: GitHubReviewWatch) => void; - updateReviewWatch: (watch: GitHubReviewWatch) => void; - removeReviewWatch: (id: string) => void; - setIssueWatches: (watches: GitHubIssueWatch[]) => void; - setIssueWatchesLoading: (loading: boolean) => void; - addIssueWatch: (watch: GitHubIssueWatch) => void; - updateIssueWatch: (watch: GitHubIssueWatch) => void; - removeIssueWatch: (id: string) => void; - setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void; - setActionPresetsLoading: (workspaceId: string, loading: boolean) => void; - applyGitHubRateLimitUpdate: (update: GitHubRateLimitUpdate) => void; - setPRFeedbackCacheEntry: (key: string, feedback: PRFeedback) => void; - removePRFeedbackCacheEntry: (key: string) => void; - // GitLab actions setTaskMRs: (mrs: Record) => void; setTaskMR: (taskId: string, mr: TaskMR) => void; @@ -279,8 +246,6 @@ export type AppState = { removeLinearIssueWatch: (id: string) => void; resetLinearIssueWatches: () => void; - // Automations actions merged via AutomationsSliceActions intersection below. - // Actions from all slices hydrate: (state: Partial, options?: HydrationOptions) => void; setActiveWorkspace: (workspaceId: string | null) => void; @@ -554,7 +519,9 @@ export type AppState = { setRunAttempts: (runId: string, attempts: RouteAttempt[]) => void; appendRunAttempt: (runId: string, attempt: RouteAttempt) => void; setAgentRouting: (agentId: string, data: AgentRouteData | undefined) => void; -} & SystemSliceActions & +} & GitHubSliceActions & + SystemSliceActions & + FeaturesSliceActions & AutomationsSliceActions; export function createAppStore(initialState?: Partial) { @@ -638,6 +605,7 @@ export function createAppStore(initialState?: Partial) { sessionPollMode: merged.sessionPollMode, githubStatus: merged.githubStatus, taskPRs: merged.taskPRs, + pendingPrUrlByTaskId: merged.pendingPrUrlByTaskId, prWatches: merged.prWatches, reviewWatches: merged.reviewWatches, issueWatches: merged.issueWatches, diff --git a/docs/remote-cloud-environment.md b/docs/remote-cloud-environment.md index f398f1a635..1821cfebe0 100644 --- a/docs/remote-cloud-environment.md +++ b/docs/remote-cloud-environment.md @@ -9,11 +9,12 @@ Setup notes and caveats for developing Kandev in ephemeral cloud VMs (Cursor Clo - **pnpm 9.15.9** — matches `packageManager` in `apps/package.json`. Install with `npm install -g pnpm@9.15.9`. - **golangci-lint v2** — required for `make lint-backend`. Install with `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest`. - **gcc** — required for CGO (SQLite FTS5). Pre-installed on most Ubuntu-based cloud VMs. -- **Azure Repos PR creation (optional)** — only needed when testing `worktree.create_pr` against Azure remotes: +- **Azure Repos PR creation (optional)** — only needed when testing `worktree.create_pr` against Azure remotes outside the published Docker image: - [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (`az`) on `PATH` - DevOps extension: `az extension add --name azure-devops` - Auth: `az login` or `export AZURE_DEVOPS_EXT_PAT=` - - GitHub PR creation still uses `gh` (see host table in `apps/backend/internal/agentctl/AGENTS.md`). + - The `ghcr.io/kdlbs/kandev` image ships `gh`, `az`, and the `azure-devops` extension preinstalled (see root `Dockerfile`). + - Host routing table: `apps/backend/internal/agentctl/AGENTS.md`. ## Generated files before typecheck