feat(vcs): add commit body field and AI generation for commit description and PR title (#465)
* feat(vcs): add commit body/description field to commit dialog
Add an optional description textarea below the commit title in both
desktop and mobile commit dialogs, with a dedicated AI generate button
for the description. Add builtin-commit-description utility agent and
E2E test.
* style: fix prettier formatting in mobile git controls and E2E test
* fix(e2e): use specific testid selectors for commit dialog inputs
The commit dialog now has two textboxes (title + body), so
getByRole("textbox") is ambiguous. Use getByTestId selectors
and add exact match for the Commit button.
* fix(ui): add cursor-pointer to mobile commit button
* fix(ui): remove "(optional)" from description label and "Generate" text from button
* fix(ui): match PR dialog style for commit description generate button
* feat(vcs): add AI generate button for PR title
Add builtin-pr-title utility agent and wire up a generate button
inside the PR title input, matching the commit title pattern.
Extract shared dialog field components into vcs-dialog-fields.tsx
to stay within the 600-line file limit.
* refactor(ui): unify generate buttons as icon-only sparkles inside inputs
Move AI generate buttons inside both title inputs and description
textareas (top-right corner) for a consistent look. Remove the
"Generate" label variant — all buttons are now icon-only sparkles.
* style: fix prettier formatting in utility agent generator hook
* fix(a11y): add aria-labels to generate buttons and PR title input
* ci: retrigger E2E tests
* fix(e2e): improve commit dialog button click reliability
Add exact match and enabled check before clicking the dialog
Commit button, increase close timeout.
* ci: retrigger checks
* fix(e2e): improve pre-hooks test commit button reliability
Add exact match, enabled check, and longer dialog timeout to match
the commit-body test pattern.
* fix(ui): prevent dialog field overflow and improve commit description prompt
- Add min-w-0 to relative wrappers in commit and PR dialog fields to
prevent text inputs/textareas from overflowing the dialog bounds
- Rewrite commit description prompt to use system-role framing that
prevents weaker models from adding conversational preamble
This commit is contained in:
@@ -42,7 +42,9 @@ func (r *sqliteRepository) getBuiltinAgents() []*models.UtilityAgent {
|
||||
now := time.Now().UTC()
|
||||
return []*models.UtilityAgent{
|
||||
r.builtinCommitMessage(now),
|
||||
r.builtinCommitDescription(now),
|
||||
r.builtinBranchName(now),
|
||||
r.builtinPRTitle(now),
|
||||
r.builtinPRDescription(now),
|
||||
r.builtinEnhancePrompt(now),
|
||||
}
|
||||
@@ -77,6 +79,33 @@ Return ONLY the commit message, no explanations or markdown.`,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinCommitDescription(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-commit-description",
|
||||
Name: "commit-description",
|
||||
Description: "Generate a detailed commit description explaining the changes",
|
||||
AgentID: "", // User must configure
|
||||
Model: "", // User must configure
|
||||
Builtin: true,
|
||||
Enabled: false, // Disabled until user configures
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Prompt: `You are a commit description generator. You output ONLY bullet points — no preamble, no commentary, no explanation, no conversational text.
|
||||
|
||||
## Staged Changes (git diff --staged):
|
||||
{{GitDiff}}
|
||||
|
||||
## Rules:
|
||||
- Output bullet points ONLY (lines starting with "• " or "- ")
|
||||
- Do NOT include any text before or after the bullet points
|
||||
- Do NOT include a commit title/subject line
|
||||
- Do NOT say things like "Here's the description" or "Based on the changes"
|
||||
- Focus on what changed and why, not which files were modified
|
||||
- Group related changes together
|
||||
- 3-8 bullet points, concise but informative`,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinBranchName(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-branch-name",
|
||||
@@ -109,6 +138,41 @@ Example: feature/add-user-authentication`,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinPRTitle(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-pr-title",
|
||||
Name: "pr-title",
|
||||
Description: "Generate a PR title based on commits and changes",
|
||||
AgentID: "", // User must configure
|
||||
Model: "", // User must configure
|
||||
Builtin: true,
|
||||
Enabled: false, // Disabled until user configures
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Prompt: `Generate a concise Pull Request title for the following changes.
|
||||
|
||||
## Commits:
|
||||
{{CommitLog}}
|
||||
|
||||
## Changed Files:
|
||||
{{ChangedFiles}}
|
||||
|
||||
## Diff Summary:
|
||||
{{DiffSummary}}
|
||||
|
||||
## Instructions:
|
||||
1. Follow the Conventional Commits format: <type>(<scope>): <description>
|
||||
2. Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
||||
3. Scope is optional but recommended (e.g., api, ui, db)
|
||||
4. Description should be imperative mood ("add" not "added")
|
||||
5. Keep it under 72 characters
|
||||
6. Focus on the overall purpose of the PR, not individual commits
|
||||
|
||||
## Output Format:
|
||||
Return ONLY the PR title, no explanations or markdown.`,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) builtinPRDescription(now time.Time) *models.UtilityAgent {
|
||||
return &models.UtilityAgent{
|
||||
ID: "builtin-pr-description",
|
||||
|
||||
@@ -91,6 +91,42 @@ function useGitToast() {
|
||||
);
|
||||
}
|
||||
|
||||
function useCommitDialogForm(
|
||||
onOpenChange: (open: boolean) => void,
|
||||
onCommit: (message: string, stageAll: boolean) => void,
|
||||
) {
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [commitBody, setCommitBody] = useState("");
|
||||
const [stageAll, setStageAll] = useState(true);
|
||||
|
||||
const handleOpen = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setCommitMessage("");
|
||||
setCommitBody("");
|
||||
setStageAll(true);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const handleCommit = () => {
|
||||
const title = commitMessage.trim();
|
||||
const body = commitBody.trim();
|
||||
const fullMessage = body ? `${title}\n\n${body}` : title;
|
||||
onCommit(fullMessage, stageAll);
|
||||
};
|
||||
|
||||
return {
|
||||
commitMessage,
|
||||
setCommitMessage,
|
||||
commitBody,
|
||||
setCommitBody,
|
||||
stageAll,
|
||||
setStageAll,
|
||||
handleOpen,
|
||||
handleCommit,
|
||||
};
|
||||
}
|
||||
|
||||
export function CommitDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -108,19 +144,10 @@ export function CommitDialog({
|
||||
isGitLoading: boolean;
|
||||
onCommit: (message: string, stageAll: boolean) => void;
|
||||
}) {
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [stageAll, setStageAll] = useState(true);
|
||||
|
||||
const handleOpen = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setCommitMessage("");
|
||||
setStageAll(true);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
const form = useCommitDialogForm(onOpenChange, onCommit);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<Dialog open={open} onOpenChange={form.handleOpen}>
|
||||
<DialogContent className="max-w-[90vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -137,16 +164,31 @@ export function CommitDialog({
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
data-testid="commit-title-input"
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
value={form.commitMessage}
|
||||
onChange={(e) => form.setCommitMessage(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="commit-body-mobile" className="text-sm">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="commit-body-mobile"
|
||||
data-testid="commit-body-input"
|
||||
placeholder="Add details about this change..."
|
||||
value={form.commitBody}
|
||||
onChange={(e) => form.setCommitBody(e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none max-h-[200px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stage-all-mobile"
|
||||
checked={stageAll}
|
||||
onCheckedChange={(checked) => setStageAll(checked === true)}
|
||||
checked={form.stageAll}
|
||||
onCheckedChange={(checked) => form.setStageAll(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="stage-all-mobile"
|
||||
@@ -163,8 +205,9 @@ export function CommitDialog({
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={() => onCommit(commitMessage.trim(), stageAll)}
|
||||
disabled={!commitMessage.trim() || isGitLoading}
|
||||
className="cursor-pointer"
|
||||
onClick={form.handleCommit}
|
||||
disabled={!form.commitMessage.trim() || isGitLoading}
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { IconLoader2, IconSparkles } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
|
||||
type GenerateButtonProps = {
|
||||
onClick: () => void;
|
||||
isGenerating: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip: string;
|
||||
notConfiguredTooltip?: string;
|
||||
isConfigured?: boolean;
|
||||
};
|
||||
|
||||
export function GenerateButton({
|
||||
onClick,
|
||||
isGenerating,
|
||||
disabled,
|
||||
tooltip,
|
||||
notConfiguredTooltip = "Configure a utility agent in settings to enable AI generation",
|
||||
isConfigured = true,
|
||||
}: GenerateButtonProps) {
|
||||
const isDisabled = !isConfigured || disabled || isGenerating;
|
||||
const tooltipText = isConfigured ? tooltip : notConfiguredTooltip;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={tooltip}
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
onClick={isConfigured ? onClick : undefined}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconSparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommitBodyField({
|
||||
commitBody,
|
||||
onCommitBodyChange,
|
||||
onGenerateDescription,
|
||||
isGeneratingDescription,
|
||||
isUtilityConfigured,
|
||||
disabled,
|
||||
}: {
|
||||
commitBody: string;
|
||||
onCommitBodyChange: (v: string) => void;
|
||||
onGenerateDescription: () => void;
|
||||
isGeneratingDescription: boolean;
|
||||
isUtilityConfigured: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vcs-commit-body" className="text-sm">
|
||||
Description
|
||||
</Label>
|
||||
<div className="relative min-w-0">
|
||||
<Textarea
|
||||
id="vcs-commit-body"
|
||||
data-testid="commit-body-input"
|
||||
placeholder="Add details about this change..."
|
||||
value={commitBody}
|
||||
onChange={(e) => onCommitBodyChange(e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none max-h-[200px] overflow-y-auto pr-10"
|
||||
/>
|
||||
<div className="absolute right-1.5 top-1.5">
|
||||
<GenerateButton
|
||||
onClick={onGenerateDescription}
|
||||
isGenerating={isGeneratingDescription}
|
||||
disabled={disabled}
|
||||
tooltip="Generate commit description with AI"
|
||||
isConfigured={isUtilityConfigured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRTitleField({
|
||||
prTitle,
|
||||
onPrTitleChange,
|
||||
onGenerateTitle,
|
||||
isGeneratingTitle,
|
||||
isUtilityConfigured,
|
||||
}: {
|
||||
prTitle: string;
|
||||
onPrTitleChange: (v: string) => void;
|
||||
onGenerateTitle: () => void;
|
||||
isGeneratingTitle: boolean;
|
||||
isUtilityConfigured: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<Input
|
||||
id="vcs-pr-title"
|
||||
aria-label="Pull request title"
|
||||
placeholder="Pull request title..."
|
||||
value={prTitle}
|
||||
onChange={(e) => onPrTitleChange(e.target.value)}
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2">
|
||||
<GenerateButton
|
||||
onClick={onGenerateTitle}
|
||||
isGenerating={isGeneratingTitle}
|
||||
tooltip="Generate PR title with AI"
|
||||
isConfigured={isUtilityConfigured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRDescriptionField({
|
||||
prBody,
|
||||
onPrBodyChange,
|
||||
onGenerateDescription,
|
||||
isGeneratingDescription,
|
||||
isUtilityConfigured,
|
||||
}: {
|
||||
prBody: string;
|
||||
onPrBodyChange: (v: string) => void;
|
||||
onGenerateDescription: () => void;
|
||||
isGeneratingDescription: boolean;
|
||||
isUtilityConfigured: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vcs-pr-body" className="text-sm">
|
||||
Description
|
||||
</Label>
|
||||
<div className="relative min-w-0">
|
||||
<Textarea
|
||||
id="vcs-pr-body"
|
||||
placeholder="Describe your changes..."
|
||||
value={prBody}
|
||||
onChange={(e) => onPrBodyChange(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none max-h-[200px] overflow-y-auto pr-10"
|
||||
/>
|
||||
<div className="absolute right-1.5 top-1.5">
|
||||
<GenerateButton
|
||||
onClick={onGenerateDescription}
|
||||
isGenerating={isGeneratingDescription}
|
||||
tooltip="Generate PR description with AI"
|
||||
isConfigured={isUtilityConfigured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRBranchSummary({
|
||||
displayBranch,
|
||||
baseBranch,
|
||||
}: {
|
||||
displayBranch?: string | null;
|
||||
baseBranch?: string;
|
||||
}) {
|
||||
if (!displayBranch) return null;
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{baseBranch ? (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span>
|
||||
{" → "}
|
||||
<span className="font-medium text-foreground">{baseBranch}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
IconGitCommit,
|
||||
IconGitPullRequest,
|
||||
IconLoader2,
|
||||
IconCheck,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconGitCommit, IconGitPullRequest, IconLoader2, IconCheck } from "@tabler/icons-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,8 +14,13 @@ 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 { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import {
|
||||
GenerateButton,
|
||||
CommitBodyField,
|
||||
PRTitleField,
|
||||
PRDescriptionField,
|
||||
PRBranchSummary,
|
||||
} from "./vcs-dialog-fields";
|
||||
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
||||
import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
|
||||
@@ -83,71 +82,22 @@ function FileSummaryText({ count, additions, deletions }: FileSummary) {
|
||||
);
|
||||
}
|
||||
|
||||
type GenerateButtonProps = {
|
||||
onClick: () => void;
|
||||
isGenerating: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip: string;
|
||||
notConfiguredTooltip?: string;
|
||||
isConfigured?: boolean;
|
||||
size?: "icon" | "sm";
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
function GenerateButton({
|
||||
onClick,
|
||||
isGenerating,
|
||||
disabled,
|
||||
tooltip,
|
||||
notConfiguredTooltip = "Configure a utility agent in settings to enable AI generation",
|
||||
isConfigured = true,
|
||||
size = "icon",
|
||||
showLabel,
|
||||
}: GenerateButtonProps) {
|
||||
const isDisabled = !isConfigured || disabled || isGenerating;
|
||||
const tooltipText = isConfigured ? tooltip : notConfiguredTooltip;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Wrap in span so tooltip works even when button is disabled */}
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
size={size}
|
||||
variant="ghost"
|
||||
className={
|
||||
size === "icon" ? "h-7 w-7 cursor-pointer" : "h-6 px-2 cursor-pointer gap-1.5 text-xs"
|
||||
}
|
||||
onClick={isConfigured ? onClick : undefined}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconSparkles className="h-4 w-4" />
|
||||
)}
|
||||
{showLabel && "Generate"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -157,12 +107,16 @@ function CommitDialog({
|
||||
fileSummary,
|
||||
commitMessage,
|
||||
onCommitMessageChange,
|
||||
commitBody,
|
||||
onCommitBodyChange,
|
||||
stageAll,
|
||||
onStageAllChange,
|
||||
isGitLoading,
|
||||
onCommit,
|
||||
onGenerateMessage,
|
||||
isGenerating,
|
||||
onGenerateDescription,
|
||||
isGeneratingDescription,
|
||||
isUtilityConfigured,
|
||||
}: CommitDialogProps) {
|
||||
return (
|
||||
@@ -178,8 +132,9 @@ function CommitDialog({
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<FileSummaryText {...fileSummary} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="relative min-w-0">
|
||||
<Input
|
||||
data-testid="commit-title-input"
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => onCommitMessageChange(e.target.value)}
|
||||
@@ -196,6 +151,14 @@ function CommitDialog({
|
||||
/>
|
||||
</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"
|
||||
@@ -245,36 +208,13 @@ type PRDialogProps = {
|
||||
onPrDraftChange: (v: boolean) => void;
|
||||
isGitLoading: boolean;
|
||||
onCreatePR: () => void;
|
||||
onGenerateTitle: () => void;
|
||||
isGeneratingTitle: boolean;
|
||||
onGenerateDescription: () => void;
|
||||
isGenerating: boolean;
|
||||
isGeneratingDescription: boolean;
|
||||
isUtilityConfigured: boolean;
|
||||
};
|
||||
|
||||
function PRBranchSummary({
|
||||
displayBranch,
|
||||
baseBranch,
|
||||
}: {
|
||||
displayBranch?: string | null;
|
||||
baseBranch?: string;
|
||||
}) {
|
||||
if (!displayBranch) return null;
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{baseBranch ? (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span>
|
||||
{" → "}
|
||||
<span className="font-medium text-foreground">{baseBranch}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PRDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -288,8 +228,10 @@ function PRDialog({
|
||||
onPrDraftChange,
|
||||
isGitLoading,
|
||||
onCreatePR,
|
||||
onGenerateTitle,
|
||||
isGeneratingTitle,
|
||||
onGenerateDescription,
|
||||
isGenerating,
|
||||
isGeneratingDescription,
|
||||
isUtilityConfigured,
|
||||
}: PRDialogProps) {
|
||||
return (
|
||||
@@ -303,41 +245,20 @@ function PRDialog({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<PRBranchSummary displayBranch={displayBranch} baseBranch={baseBranch} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vcs-pr-title" className="text-sm">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="vcs-pr-title"
|
||||
placeholder="Pull request title..."
|
||||
value={prTitle}
|
||||
onChange={(e) => onPrTitleChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="vcs-pr-body" className="text-sm">
|
||||
Description
|
||||
</Label>
|
||||
<GenerateButton
|
||||
onClick={onGenerateDescription}
|
||||
isGenerating={isGenerating}
|
||||
tooltip="Generate PR description with AI"
|
||||
isConfigured={isUtilityConfigured}
|
||||
size="sm"
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
id="vcs-pr-body"
|
||||
placeholder="Describe your changes..."
|
||||
value={prBody}
|
||||
onChange={(e) => onPrBodyChange(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none max-h-[200px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
@@ -379,6 +300,8 @@ type UseCommitDialogReturn = {
|
||||
setOpen: (v: boolean) => void;
|
||||
message: string;
|
||||
setMessage: (v: string) => void;
|
||||
body: string;
|
||||
setBody: (v: string) => void;
|
||||
stageAll: boolean;
|
||||
setStageAll: (v: boolean) => void;
|
||||
openDialog: () => void;
|
||||
@@ -387,13 +310,15 @@ type UseCommitDialogReturn = {
|
||||
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, stageAll, setStageAll, openDialog };
|
||||
return { open, setOpen, message, setMessage, body, setBody, stageAll, setStageAll, openDialog };
|
||||
}
|
||||
|
||||
type UsePRDialogReturn = {
|
||||
@@ -475,8 +400,12 @@ export function VcsDialogsProvider({
|
||||
const isUtilityConfigured = useIsUtilityConfigured();
|
||||
const {
|
||||
isGeneratingCommitMessage,
|
||||
isGeneratingCommitDescription,
|
||||
isGeneratingPRTitle,
|
||||
isGeneratingPRDescription,
|
||||
generateCommitMessage,
|
||||
generateCommitDescription,
|
||||
generatePRTitle,
|
||||
generatePRDescription,
|
||||
} = useUtilityAgentGenerator({ sessionId, taskTitle });
|
||||
const fileSummary = computeFileSummary(gitStatus?.files);
|
||||
@@ -484,8 +413,12 @@ export function VcsDialogsProvider({
|
||||
const handleCommit = useCallback(async () => {
|
||||
if (!cs.message.trim()) return;
|
||||
cs.setOpen(false);
|
||||
await gitWithFeedback(() => commit(cs.message.trim(), cs.stageAll), "Commit");
|
||||
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);
|
||||
@@ -507,12 +440,16 @@ export function VcsDialogsProvider({
|
||||
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
|
||||
@@ -528,8 +465,10 @@ export function VcsDialogsProvider({
|
||||
onPrDraftChange={ps.setDraft}
|
||||
isGitLoading={isGitLoading}
|
||||
onCreatePR={handleCreatePR}
|
||||
onGenerateTitle={() => generatePRTitle(ps.setTitle)}
|
||||
isGeneratingTitle={isGeneratingPRTitle}
|
||||
onGenerateDescription={() => generatePRDescription(ps.setBody)}
|
||||
isGenerating={isGeneratingPRDescription}
|
||||
isGeneratingDescription={isGeneratingPRDescription}
|
||||
isUtilityConfigured={isUtilityConfigured}
|
||||
/>
|
||||
</VcsDialogsContext.Provider>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { test, expect } from "../fixtures/test-base";
|
||||
import type { ApiClient } from "../helpers/api-client";
|
||||
import { KanbanPage } from "../pages/kanban-page";
|
||||
import { SessionPage } from "../pages/session-page";
|
||||
import type { Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (same patterns as git-changes-panel.spec.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class GitHelper {
|
||||
constructor(
|
||||
private repoDir: string,
|
||||
private env: NodeJS.ProcessEnv,
|
||||
) {}
|
||||
|
||||
exec(cmd: string): string {
|
||||
const lockPath = path.join(this.repoDir, ".git", "index.lock");
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
|
||||
try {
|
||||
return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" });
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? "";
|
||||
if (msg.includes("index.lock") && attempt < 2) {
|
||||
execSync("sleep 0.2");
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error(`git exec failed after 3 attempts: ${cmd}`);
|
||||
}
|
||||
|
||||
createFile(name: string, content: string) {
|
||||
fs.writeFileSync(path.join(this.repoDir, name), content);
|
||||
}
|
||||
|
||||
getLastCommitMessage(): string {
|
||||
return this.exec("git log -1 --format=%B").trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function createStandardProfile(apiClient: ApiClient, name: string) {
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agentId = agents[0]?.id;
|
||||
if (!agentId) {
|
||||
throw new Error(`E2E setup failed: no agent available for profile "${name}"`);
|
||||
}
|
||||
return apiClient.createAgentProfile(agentId, name, {
|
||||
model: "mock-fast",
|
||||
auto_approve: true,
|
||||
cli_passthrough: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function openTaskSession(page: Page, title: string): Promise<SessionPage> {
|
||||
const kanban = new KanbanPage(page);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle(title);
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(page).toHaveURL(/\/s\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad();
|
||||
return session;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Git commit body", () => {
|
||||
test("commit dialog includes body textarea and commits with title + body", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const profile = await createStandardProfile(apiClient, "Git Commit Body Profile");
|
||||
|
||||
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Commit Body Test", profile.id, {
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
|
||||
const session = await openTaskSession(testPage, "Git Commit Body Test");
|
||||
|
||||
// Wait for agent to finish initial turn
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Set up git helper
|
||||
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
HOME: backend.tmpDir,
|
||||
GIT_AUTHOR_NAME: "E2E Test",
|
||||
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
||||
GIT_COMMITTER_NAME: "E2E Test",
|
||||
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
||||
};
|
||||
const git = new GitHelper(repoDir, gitEnv);
|
||||
|
||||
// Open Changes tab first so WS subscription is active
|
||||
await session.clickTab("Changes");
|
||||
await expect(session.changes).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Create a file - it should appear as unstaged via git status polling
|
||||
git.createFile("commit-body-test.txt", "test content for commit body");
|
||||
await expect(session.changes.getByText("commit-body-test.txt")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Stage the file via the UI "Stage all" button
|
||||
await session.changes.getByRole("button", { name: "Stage all" }).click();
|
||||
|
||||
// Wait for the "Commit" button to appear (indicates staged section is ready)
|
||||
const commitButton = session.changes.getByRole("button", { name: "Commit", exact: true });
|
||||
await expect(commitButton).toBeVisible({ timeout: 15_000 });
|
||||
await commitButton.click();
|
||||
|
||||
// The dialog should open
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify both title input and body textarea are visible
|
||||
const titleInput = dialog.getByTestId("commit-title-input");
|
||||
const bodyInput = dialog.getByTestId("commit-body-input");
|
||||
await expect(titleInput).toBeVisible();
|
||||
await expect(bodyInput).toBeVisible();
|
||||
|
||||
// Fill title and body
|
||||
await titleInput.fill("feat: add commit body support");
|
||||
await bodyInput.fill("Detailed description of the change");
|
||||
|
||||
// Click Commit in dialog
|
||||
const dialogCommitBtn = dialog.getByRole("button", { name: "Commit", exact: true });
|
||||
await expect(dialogCommitBtn).toBeEnabled();
|
||||
await dialogCommitBtn.click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Verify the commit message in the git repository includes both title and body
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
try {
|
||||
return git.getLastCommitMessage();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
{ timeout: 15_000, message: "Expected commit with title and body" },
|
||||
)
|
||||
.toContain("feat: add commit body support");
|
||||
|
||||
const fullMessage = git.getLastCommitMessage();
|
||||
expect(fullMessage).toContain("feat: add commit body support");
|
||||
expect(fullMessage).toContain("Detailed description of the change");
|
||||
|
||||
// Verify title and body are separated by a blank line (git convention)
|
||||
const lines = fullMessage.split("\n");
|
||||
expect(lines[0]).toBe("feat: add commit body support");
|
||||
expect(lines[1]).toBe("");
|
||||
expect(lines[2]).toBe("Detailed description of the change");
|
||||
});
|
||||
});
|
||||
@@ -132,9 +132,11 @@ test.describe("Git commit pre-hooks", () => {
|
||||
|
||||
// Fill commit message and submit
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.getByRole("textbox").fill("test commit message");
|
||||
await dialog.getByRole("button", { name: "Commit" }).click();
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
await dialog.getByTestId("commit-title-input").fill("test commit message");
|
||||
const dialogCommitBtn = dialog.getByRole("button", { name: "Commit", exact: true });
|
||||
await expect(dialogCommitBtn).toBeEnabled();
|
||||
await dialogCommitBtn.click();
|
||||
|
||||
// The error message should appear in the chat
|
||||
await expect(session.gitOperationErrorMessage()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -6,10 +6,17 @@ import { useToast } from "@/components/toast-provider";
|
||||
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
||||
import type { FileInfo } from "@/lib/state/slices";
|
||||
|
||||
type GeneratorType = "commit-message" | "pr-description" | "enhance-prompt";
|
||||
type GeneratorType =
|
||||
| "commit-message"
|
||||
| "commit-description"
|
||||
| "pr-title"
|
||||
| "pr-description"
|
||||
| "enhance-prompt";
|
||||
|
||||
const UTILITY_AGENT_IDS: Record<GeneratorType, string> = {
|
||||
"commit-message": "builtin-commit-message",
|
||||
"commit-description": "builtin-commit-description",
|
||||
"pr-title": "builtin-pr-title",
|
||||
"pr-description": "builtin-pr-description",
|
||||
"enhance-prompt": "builtin-enhance-prompt",
|
||||
};
|
||||
@@ -34,7 +41,7 @@ export function useUtilityAgentGenerator({
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
}: UseUtilityAgentGeneratorOptions) {
|
||||
const [isGenerating, setIsGenerating] = useState<GeneratorType | null>(null);
|
||||
const [generating, setGenerating] = useState<Set<GeneratorType>>(new Set());
|
||||
const { toast } = useToast();
|
||||
const gitStatus = useSessionGitStatus(sessionId);
|
||||
|
||||
@@ -60,7 +67,7 @@ export function useUtilityAgentGenerator({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(type);
|
||||
setGenerating((prev) => new Set(prev).add(type));
|
||||
try {
|
||||
const { changedFiles, diffs } = collectGitContext();
|
||||
|
||||
@@ -79,10 +86,13 @@ export function useUtilityAgentGenerator({
|
||||
const resp = await executeUtilityPrompt(request);
|
||||
|
||||
// Clear generating state BEFORE calling onSuccess so the editor is re-enabled
|
||||
setIsGenerating(null);
|
||||
setGenerating((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(type);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (resp.success && resp.response) {
|
||||
// Use requestAnimationFrame to ensure React has re-rendered with editor enabled
|
||||
const response = resp.response;
|
||||
requestAnimationFrame(() => {
|
||||
options?.onSuccess?.(response);
|
||||
@@ -95,7 +105,11 @@ export function useUtilityAgentGenerator({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setIsGenerating(null);
|
||||
setGenerating((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(type);
|
||||
return next;
|
||||
});
|
||||
toast({
|
||||
title: "Generation failed",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
@@ -106,10 +120,26 @@ export function useUtilityAgentGenerator({
|
||||
[sessionId, taskTitle, taskDescription, collectGitContext, toast],
|
||||
);
|
||||
|
||||
return useGeneratorCallbacks(generate, generating);
|
||||
}
|
||||
|
||||
function useGeneratorCallbacks(
|
||||
generate: (type: GeneratorType, options?: GenerateOptions) => Promise<void>,
|
||||
generating: Set<GeneratorType>,
|
||||
) {
|
||||
const generateCommitMessage = useCallback(
|
||||
(onSuccess: (message: string) => void) => {
|
||||
return generate("commit-message", { onSuccess });
|
||||
},
|
||||
(onSuccess: (message: string) => void) => generate("commit-message", { onSuccess }),
|
||||
[generate],
|
||||
);
|
||||
|
||||
const generateCommitDescription = useCallback(
|
||||
(onSuccess: (description: string) => void) => generate("commit-description", { onSuccess }),
|
||||
[generate],
|
||||
);
|
||||
|
||||
const generatePRTitle = useCallback(
|
||||
(onSuccess: (title: string) => void, extra?: { commitLog?: string; diffSummary?: string }) =>
|
||||
generate("pr-title", { onSuccess, ...extra }),
|
||||
[generate],
|
||||
);
|
||||
|
||||
@@ -117,25 +147,26 @@ export function useUtilityAgentGenerator({
|
||||
(
|
||||
onSuccess: (description: string) => void,
|
||||
extra?: { commitLog?: string; diffSummary?: string },
|
||||
) => {
|
||||
return generate("pr-description", { onSuccess, ...extra });
|
||||
},
|
||||
) => generate("pr-description", { onSuccess, ...extra }),
|
||||
[generate],
|
||||
);
|
||||
|
||||
const enhancePrompt = useCallback(
|
||||
(userPrompt: string, onSuccess: (enhanced: string) => void) => {
|
||||
return generate("enhance-prompt", { onSuccess, userPrompt });
|
||||
},
|
||||
(userPrompt: string, onSuccess: (enhanced: string) => void) =>
|
||||
generate("enhance-prompt", { onSuccess, userPrompt }),
|
||||
[generate],
|
||||
);
|
||||
|
||||
return {
|
||||
isGenerating,
|
||||
isGeneratingCommitMessage: isGenerating === "commit-message",
|
||||
isGeneratingPRDescription: isGenerating === "pr-description",
|
||||
isEnhancingPrompt: isGenerating === "enhance-prompt",
|
||||
isGenerating: generating.size > 0,
|
||||
isGeneratingCommitMessage: generating.has("commit-message"),
|
||||
isGeneratingCommitDescription: generating.has("commit-description"),
|
||||
isGeneratingPRTitle: generating.has("pr-title"),
|
||||
isGeneratingPRDescription: generating.has("pr-description"),
|
||||
isEnhancingPrompt: generating.has("enhance-prompt"),
|
||||
generateCommitMessage,
|
||||
generateCommitDescription,
|
||||
generatePRTitle,
|
||||
generatePRDescription,
|
||||
enhancePrompt,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user