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:
José Almeida
2026-03-26 13:24:30 +00:00
committed by GitHub
parent ec01a55354
commit 7d900fea91
7 changed files with 620 additions and 167 deletions
@@ -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>
);
}
+67 -128
View File
@@ -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>
+175
View File
@@ -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 });
+50 -19
View File
@@ -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,
};