fix(ai): sync auto-commit with streamed result to surface commit SHA to UI (Fix 10)

This commit is contained in:
2026-05-16 12:26:54 -07:00
parent 89c9b01669
commit ca8a915fe2

View File

@@ -872,55 +872,70 @@ export async function POST(request: Request) {
// view — without it, every fs.write / shell.exec mutation // view — without it, every fs.write / shell.exec mutation
// stays trapped in the dev container's volume. // stays trapped in the dev container's volume.
// //
// Run AFTER the assistant message is persisted because the // Run BEFORE the final done event so we can surface the commit
// user already saw the reply; a slow push shouldn't block // result in the UI (Fix 10).
// the chat. If there's nothing to commit, the helper short-
// circuits with reason='clean' in <1s.
if ( if (
activeProject?.id && activeProject?.id &&
activeProject?.slug && activeProject?.slug &&
typeof activeProject?.giteaCloneUrl === "string" typeof activeProject?.giteaCloneUrl === "string"
) { ) {
(async () => { try {
try { // Best-effort clone in case the pre-loop kick-off was
// Best-effort clone in case the pre-loop kick-off was // racing with container provisioning and never landed.
// racing with container provisioning and never landed. await ensureProjectRepoCloned({
await ensureProjectRepoCloned({ projectId: activeProject.id,
projectId: activeProject.id, projectSlug: activeProject.slug,
projectSlug: activeProject.slug, giteaCloneUrl: activeProject.giteaCloneUrl,
giteaCloneUrl: activeProject.giteaCloneUrl, }).catch(() => null);
}).catch(() => null); // Commit message: prefer the assistant's own first
// Commit message: prefer the assistant's own first // sentence (one line, ≤200 chars). Falls back to a
// sentence (one line, ≤200 chars). Falls back to a // generic checkpoint when the assistant only made
// generic checkpoint when the assistant only made // tool calls without prose.
// tool calls without prose. const firstSentence = (assistantText || "")
const firstSentence = (assistantText || "") .split(/(?<=[.!?])\s+/)[0]
.split(/(?<=[.!?])\s+/)[0] ?.trim()
?.trim() ?.slice(0, 180);
?.slice(0, 180); const commitMessage = firstSentence || "AI checkpoint";
const message = firstSentence || "AI checkpoint";
const result = await commitAndPushIfDirty({ const commitPromise = commitAndPushIfDirty({
projectId: activeProject.id, projectId: activeProject.id,
projectSlug: activeProject.slug, projectSlug: activeProject.slug,
message, message: commitMessage,
}); });
if (result.committed) { const timeoutPromise = new Promise<{
console.log( committed: false;
`[chat] auto-commit project=${activeProject.slug} sha=${result.sha} pushed=${result.pushed}`, reason: string;
); }>((resolve) =>
} else if ( setTimeout(
result.reason && () => resolve({ committed: false, reason: "timeout" }),
result.reason !== "clean" && 8000,
result.reason !== "no_repo" ),
) { );
console.warn(
`[chat] auto-commit failed project=${activeProject.slug} reason=${result.reason}`, const result = (await Promise.race([
); commitPromise,
} timeoutPromise,
} catch (err) { ])) as any;
console.warn("[chat] auto-commit fire-and-forget failed", err);
if (result.committed) {
emit({ type: "commit", sha: result.sha, pushed: result.pushed });
console.log(
`[chat] auto-commit project=${activeProject.slug} sha=${result.sha} pushed=${result.pushed}`,
);
} else if (
result.reason &&
result.reason !== "clean" &&
result.reason !== "no_repo"
) {
emit({ type: "commit_failed", reason: result.reason });
console.warn(
`[chat] auto-commit failed project=${activeProject.slug} reason=${result.reason}`,
);
} }
})(); } catch (err) {
emit({ type: "commit_failed", reason: String(err) });
console.warn("[chat] auto-commit failed", err);
}
} }
// Fire-and-forget: ask Gemini for a 1-2 sentence "what got done" // Fire-and-forget: ask Gemini for a 1-2 sentence "what got done"