feat(ai): add AI-assisted commit splitting flow
Introduce a split-planning path for staged changes that asks supported AI providers to group files into ordered Conventional Commit messages. The plan is validated before execution so every staged file is assigned once and unsafe states are rejected. A new dialog lets users review and adjust the proposed groups before creating the commits in sequence, with safeguards to preserve remaining changes if something fails.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
||||
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
||||
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
||||
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||
@@ -51,6 +52,7 @@
|
||||
commit,
|
||||
commitAiGenerate,
|
||||
commitAiReview,
|
||||
commitAiSplit,
|
||||
commitAiLoad,
|
||||
commitAiLocalModels,
|
||||
commitAiStatus,
|
||||
@@ -129,6 +131,7 @@
|
||||
|
||||
import type {
|
||||
AiReviewResult,
|
||||
AiCommitPlan,
|
||||
AiSettings,
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
@@ -293,6 +296,9 @@
|
||||
let commitAiPhase: CommitAiPhase = "idle";
|
||||
let commitAiGenerating = false;
|
||||
let commitAiReviewing = false;
|
||||
let commitAiSplitting = false;
|
||||
let aiCommitPlan: AiCommitPlan | null = null;
|
||||
let aiCommitSplitOpen = false;
|
||||
let aiReviewResult: AiReviewResult | null = null;
|
||||
let aiReviewOpen = false;
|
||||
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
@@ -1034,6 +1040,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function splitStagedWithAi() {
|
||||
if (!activeRepoPath || commitAiSplitting || stagedCount < 2) return;
|
||||
if (aiSettings.provider === "local") {
|
||||
errorMessage = "Commit splitting currently requires OpenAI, Anthropic, or a custom endpoint.";
|
||||
return;
|
||||
}
|
||||
commitAiSplitting = true;
|
||||
errorMessage = "";
|
||||
try {
|
||||
if (aiSettings.provider === "openai") {
|
||||
const cred = await credLoad("ai:openai");
|
||||
aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "openai", model: aiSettings.openaiModel, apiKey: cred?.password });
|
||||
} else if (aiSettings.provider === "anthropic") {
|
||||
const cred = await credLoad("ai:anthropic");
|
||||
aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "anthropic", model: aiSettings.anthropicModel, apiKey: cred?.password });
|
||||
} else {
|
||||
const cred = await credLoad("ai:custom");
|
||||
aiCommitPlan = await commitAiSplit(activeRepoPath, {
|
||||
provider: "custom", model: aiSettings.customModel, baseUrl: aiSettings.customBaseUrl, apiKey: cred?.password,
|
||||
});
|
||||
}
|
||||
aiCommitSplitOpen = true;
|
||||
trackEvent("ai_commit_split_planned", { provider: aiSettings.provider, groups: aiCommitPlan.groups.length });
|
||||
} catch (error) {
|
||||
errorMessage = errorToMessage(error);
|
||||
} finally {
|
||||
commitAiSplitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAiCommitPlan(plan: AiCommitPlan) {
|
||||
if (!activeRepoPath || isBusy || commitAiSplitting) return;
|
||||
const allFiles = plan.groups.flatMap((group) => group.files);
|
||||
if (plan.groups.length < 2 || plan.groups.some((group) => !group.message.trim() || group.files.length === 0)) return;
|
||||
const currentStaged = changedFiles.filter((file) => file.staged !== null).map((file) => file.path).sort();
|
||||
if (currentStaged.join("\n") !== [...allFiles].sort().join("\n")) {
|
||||
errorMessage = "The staged files changed after the plan was created. Generate a new split plan.";
|
||||
return;
|
||||
}
|
||||
if (changedFiles.some((file) => file.staged !== null && file.unstaged !== null)) {
|
||||
errorMessage = "A file now has both staged and unstaged changes. Stage or discard the remaining changes first.";
|
||||
return;
|
||||
}
|
||||
|
||||
commitAiSplitting = true;
|
||||
await runOperation("Creating split commits", async () => {
|
||||
applyStatus(await unstageFiles(activeRepoPath, currentStaged));
|
||||
let completed = 0;
|
||||
try {
|
||||
for (const group of plan.groups) {
|
||||
applyStatus(await stageFiles(activeRepoPath, group.files));
|
||||
applyStatus(await commit(activeRepoPath, group.message.trim()));
|
||||
completed += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`${completed} of ${plan.groups.length} commits were created. Remaining changes are preserved and can be staged again. ${errorToMessage(error)}`);
|
||||
}
|
||||
aiCommitSplitOpen = false;
|
||||
aiCommitPlan = null;
|
||||
commitMessage = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length });
|
||||
});
|
||||
commitAiSplitting = false;
|
||||
}
|
||||
|
||||
// ── Updates ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function checkForUpdates() {
|
||||
@@ -4440,12 +4515,14 @@
|
||||
{commitAiPhase}
|
||||
{commitAiGenerating}
|
||||
{commitAiReviewing}
|
||||
{commitAiSplitting}
|
||||
{canAmend}
|
||||
{amendMode}
|
||||
onCommit={commitChanges}
|
||||
onCommitMessageChange={updateCommitMessage}
|
||||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||
onReviewStaged={reviewStagedWithAi}
|
||||
onSplitStaged={splitStagedWithAi}
|
||||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||||
onToggleAmend={toggleAmendMode}
|
||||
onUndoLastCommit={undoLastCommitChange}
|
||||
@@ -4588,6 +4665,15 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if aiCommitSplitOpen && aiCommitPlan}
|
||||
<AiCommitSplitDialog
|
||||
plan={aiCommitPlan}
|
||||
isApplying={commitAiSplitting}
|
||||
onApply={applyAiCommitPlan}
|
||||
onClose={() => { if (!commitAiSplitting) aiCommitSplitOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if linePatchOpen && linePatchFile}
|
||||
<LinePatchDialog
|
||||
file={linePatchFile}
|
||||
|
||||
Reference in New Issue
Block a user