Files
kandev/apps/web/components/vcs/vcs-dialogs.tsx
T
Kandev Agent 9e8079de69 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.
2026-04-26 21:23:48 +01:00

481 lines
15 KiB
TypeScript

"use client";
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react";
import { IconGitCommit, IconGitPullRequest, IconLoader2, IconCheck } from "@tabler/icons-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import {
GenerateButton,
CommitBodyField,
PRTitleField,
PRDescriptionField,
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";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useToast } from "@/components/toast-provider";
import type { FileInfo } from "@/lib/state/slices";
type VcsDialogsContextValue = {
openCommitDialog: () => void;
openPRDialog: () => void;
};
const VcsDialogsContext = createContext<VcsDialogsContextValue | null>(null);
export function useVcsDialogs() {
const ctx = useContext(VcsDialogsContext);
if (!ctx) throw new Error("useVcsDialogs must be used within VcsDialogsProvider");
return ctx;
}
type VcsDialogsProviderProps = {
sessionId: string | null;
baseBranch?: string;
taskTitle?: string;
displayBranch?: string | null;
children: ReactNode;
};
type FileSummary = { count: number; additions: number; deletions: number };
function computeFileSummary(files: Record<string, FileInfo> | undefined): FileSummary {
const count = files ? Object.keys(files).length : 0;
let additions = 0;
let deletions = 0;
if (files && count > 0) {
for (const file of Object.values(files) as FileInfo[]) {
additions += file.additions || 0;
deletions += file.deletions || 0;
}
}
return { count, additions, deletions };
}
function FileSummaryText({ count, additions, deletions }: FileSummary) {
if (count === 0) return <span>No changes to commit</span>;
return (
<span>
<span className="font-medium text-foreground">{count}</span> file{count !== 1 ? "s" : ""}{" "}
changed
{(additions > 0 || deletions > 0) && (
<span className="ml-2">
(<span className="text-green-600">+{additions}</span>
{" / "}
<span className="text-red-600">-{deletions}</span>)
</span>
)}
</span>
);
}
type CommitDialogProps = {
open: boolean;
onOpenChange: (v: boolean) => void;
fileSummary: FileSummary;
commitMessage: string;
onCommitMessageChange: (v: string) => void;
commitBody: string;
onCommitBodyChange: (v: string) => void;
stageAll: boolean;
onStageAllChange: (v: boolean) => void;
isGitLoading: boolean;
onCommit: () => void;
onGenerateMessage: () => void;
isGenerating: boolean;
onGenerateDescription: () => void;
isGeneratingDescription: boolean;
isUtilityConfigured: boolean;
};
function CommitDialog({
open,
onOpenChange,
fileSummary,
commitMessage,
onCommitMessageChange,
commitBody,
onCommitBodyChange,
stageAll,
onStageAllChange,
isGitLoading,
onCommit,
onGenerateMessage,
isGenerating,
onGenerateDescription,
isGeneratingDescription,
isUtilityConfigured,
}: CommitDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitCommit className="h-5 w-5" />
Commit Changes
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
<FileSummaryText {...fileSummary} />
</div>
<div className="relative min-w-0">
<Input
data-testid="commit-title-input"
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
className="pr-10"
autoFocus
/>
<div className="absolute right-1.5 top-1/2 -translate-y-1/2">
<GenerateButton
onClick={onGenerateMessage}
isGenerating={isGenerating}
disabled={fileSummary.count === 0}
tooltip="Generate commit message with AI"
isConfigured={isUtilityConfigured}
/>
</div>
</div>
<CommitBodyField
commitBody={commitBody}
onCommitBodyChange={onCommitBodyChange}
onGenerateDescription={onGenerateDescription}
isGeneratingDescription={isGeneratingDescription}
isUtilityConfigured={isUtilityConfigured}
disabled={fileSummary.count === 0}
/>
<div className="flex items-center gap-2">
<Checkbox
id="vcs-stage-all"
checked={stageAll}
onCheckedChange={(checked) => onStageAllChange(checked === true)}
/>
<Label htmlFor="vcs-stage-all" className="text-sm text-muted-foreground cursor-pointer">
Stage all changes before committing
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" className="cursor-pointer">
Cancel
</Button>
</DialogClose>
<Button onClick={onCommit} disabled={!commitMessage.trim() || isGitLoading}>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Committing...
</>
) : (
<>
<IconCheck className="h-4 w-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
type PRDialogProps = {
open: boolean;
onOpenChange: (v: boolean) => void;
displayBranch?: string | null;
baseBranch?: string;
prTitle: string;
onPrTitleChange: (v: string) => void;
prBody: string;
onPrBodyChange: (v: string) => void;
prDraft: boolean;
onPrDraftChange: (v: boolean) => void;
isGitLoading: boolean;
onCreatePR: () => void;
onGenerateTitle: () => void;
isGeneratingTitle: boolean;
onGenerateDescription: () => void;
isGeneratingDescription: boolean;
isUtilityConfigured: boolean;
};
function PRDialog({
open,
onOpenChange,
displayBranch,
baseBranch,
prTitle,
onPrTitleChange,
prBody,
onPrBodyChange,
prDraft,
onPrDraftChange,
isGitLoading,
onCreatePR,
onGenerateTitle,
isGeneratingTitle,
onGenerateDescription,
isGeneratingDescription,
isUtilityConfigured,
}: PRDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconGitPullRequest className="h-5 w-5" />
Create Pull Request
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<PRBranchSummary displayBranch={displayBranch} baseBranch={baseBranch} />
<PRTitleField
prTitle={prTitle}
onPrTitleChange={onPrTitleChange}
onGenerateTitle={onGenerateTitle}
isGeneratingTitle={isGeneratingTitle}
isUtilityConfigured={isUtilityConfigured}
/>
<PRDescriptionField
prBody={prBody}
onPrBodyChange={onPrBodyChange}
onGenerateDescription={onGenerateDescription}
isGeneratingDescription={isGeneratingDescription}
isUtilityConfigured={isUtilityConfigured}
/>
<div className="flex items-center space-x-2">
<Checkbox
id="vcs-pr-draft"
checked={prDraft}
onCheckedChange={(checked) => onPrDraftChange(checked === true)}
/>
<Label htmlFor="vcs-pr-draft" className="text-sm cursor-pointer">
Create as draft
</Label>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" className="cursor-pointer">
Cancel
</Button>
</DialogClose>
<Button onClick={onCreatePR} disabled={!prTitle.trim() || isGitLoading}>
{isGitLoading ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
<>
<IconGitPullRequest className="h-4 w-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
type UseCommitDialogReturn = {
open: boolean;
setOpen: (v: boolean) => void;
message: string;
setMessage: (v: string) => void;
body: string;
setBody: (v: string) => void;
stageAll: boolean;
setStageAll: (v: boolean) => void;
openDialog: () => void;
};
function useCommitDialogState(): UseCommitDialogReturn {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [body, setBody] = useState("");
const [stageAll, setStageAll] = useState(true);
const openDialog = useCallback(() => {
setMessage("");
setBody("");
setStageAll(true);
setOpen(true);
}, []);
return { open, setOpen, message, setMessage, body, setBody, stageAll, setStageAll, openDialog };
}
type UsePRDialogReturn = {
open: boolean;
setOpen: (v: boolean) => void;
title: string;
setTitle: (v: string) => void;
body: string;
setBody: (v: string) => void;
draft: boolean;
setDraft: (v: boolean) => void;
openDialog: (taskTitle?: string) => void;
};
function usePRDialogState(): UsePRDialogReturn {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [draft, setDraft] = useState(true);
const openDialog = useCallback((taskTitle?: string) => {
setTitle(taskTitle || "");
setBody("");
setOpen(true);
}, []);
return { open, setOpen, title, setTitle, body, setBody, draft, setDraft, openDialog };
}
function useCreatePRHandler(
ps: UsePRDialogReturn,
baseBranch: string | undefined,
createPR: ReturnType<typeof useGitOperations>["createPR"],
toast: ReturnType<typeof useToast>["toast"],
) {
return useCallback(async () => {
if (!ps.title.trim()) return;
ps.setOpen(false);
try {
const result = await createPR(ps.title.trim(), ps.body.trim(), baseBranch, ps.draft);
if (result.success) {
const title = ps.draft ? "Draft PR created" : "PR created";
toast({
title,
description: result.pr_url || "PR created successfully",
variant: "success",
});
if (result.pr_url) window.open(result.pr_url, "_blank");
} else {
toast({
title: "Create PR failed",
description: result.error || "An error occurred",
variant: "error",
});
}
} catch (e) {
toast({
title: "Create PR failed",
description: e instanceof Error ? e.message : "An error occurred",
variant: "error",
});
}
ps.setTitle("");
ps.setBody("");
}, [ps, baseBranch, createPR, toast]);
}
export function VcsDialogsProvider({
sessionId,
baseBranch,
taskTitle,
displayBranch,
children,
}: VcsDialogsProviderProps) {
const cs = useCommitDialogState();
const ps = usePRDialogState();
const { toast } = useToast();
const gitWithFeedback = useGitWithFeedback();
const gitStatus = useSessionGitStatus(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,
isGeneratingCommitDescription,
isGeneratingPRTitle,
isGeneratingPRDescription,
generateCommitMessage,
generateCommitDescription,
generatePRTitle,
generatePRDescription,
} = useUtilityAgentGenerator({ sessionId, taskTitle });
const fileSummary = computeFileSummary(gitStatus?.files);
const handleCommit = useCallback(async () => {
if (!cs.message.trim()) return;
cs.setOpen(false);
const title = cs.message.trim();
const body = cs.body.trim();
const fullMessage = body ? `${title}\n\n${body}` : title;
await gitWithFeedback(() => commit(fullMessage, cs.stageAll), "Commit");
cs.setMessage("");
cs.setBody("");
}, [cs, gitWithFeedback, commit]);
const handleCreatePR = useCreatePRHandler(ps, baseBranch, createPR, toast);
const contextValue = useMemo(
() => ({
openCommitDialog: cs.openDialog,
openPRDialog: () => ps.openDialog(taskTitle),
}),
[cs.openDialog, ps, taskTitle],
);
return (
<VcsDialogsContext.Provider value={contextValue}>
{children}
<CommitDialog
open={cs.open}
onOpenChange={cs.setOpen}
fileSummary={fileSummary}
commitMessage={cs.message}
onCommitMessageChange={cs.setMessage}
commitBody={cs.body}
onCommitBodyChange={cs.setBody}
stageAll={cs.stageAll}
onStageAllChange={cs.setStageAll}
isGitLoading={isGitLoading}
onCommit={handleCommit}
onGenerateMessage={() => generateCommitMessage(cs.setMessage)}
isGenerating={isGeneratingCommitMessage}
onGenerateDescription={() => generateCommitDescription(cs.setBody)}
isGeneratingDescription={isGeneratingCommitDescription}
isUtilityConfigured={isUtilityConfigured}
/>
<PRDialog
open={ps.open}
onOpenChange={ps.setOpen}
displayBranch={displayBranch}
baseBranch={baseBranch}
prTitle={ps.title}
onPrTitleChange={ps.setTitle}
prBody={ps.body}
onPrBodyChange={ps.setBody}
prDraft={ps.draft}
onPrDraftChange={ps.setDraft}
isGitLoading={isGitLoading}
onCreatePR={handleCreatePR}
onGenerateTitle={() => generatePRTitle(ps.setTitle)}
isGeneratingTitle={isGeneratingPRTitle}
onGenerateDescription={() => generatePRDescription(ps.setBody)}
isGeneratingDescription={isGeneratingPRDescription}
isUtilityConfigured={isUtilityConfigured}
/>
</VcsDialogsContext.Provider>
);
}