FN-7181: improve PR metadata generation feedback

Ground PR metadata generation in repository evidence and make Create PR loading states explicit.

- Upgrade the PR metadata system/context prompt while preserving configurable title and description guidance.\n- Show disabled title/body skeleton states while AI metadata generates and block submission until content resolves.\n- Extend dashboard tests, docs, and changeset coverage for the improved PR prompt and loading behavior.\n\nFiles changed:\n .../fn-7181-pr-generation-prompt-and-loading.md    |  7 +++\n docs/dashboard-guide.md                            |  2 +-\n .../dashboard/app/components/PrCreateModal.css     | 62 ++++++++++++++++++++++\n .../dashboard/app/components/PrCreateModal.tsx     | 47 +++++++++++++---\n .../components/__tests__/PrCreateModal.test.tsx    | 60 ++++++++++++++++-----\n .../src/__tests__/pr-metadata-generator.test.ts    | 44 ++++++++++++---\n packages/dashboard/src/pr-metadata-generator.ts    | 33 +++++++++---\n 7 files changed, 221 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-7181

Fusion-Task-Lineage: 6565ddce-c205-4050-a37f-ffbb2f2d46d4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 01:17:22 -07:00
parent 3a52c7a7cd
commit 65abae26c6
7 changed files with 221 additions and 34 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Improve AI-generated PR titles and descriptions, and show a clear loading state while the description generates.
category: feature
dev: Rewrote the pr-metadata-generator system/context prompt (exported as a named default constant) for grounded, conventional-commit-style output while preserving the strict {title,summary,changes,testing,linkedTask} JSON schema; PrCreateModal now renders a skeleton + aria-busy loading affordance with disabled inputs during generation that clears into content or the existing error/manual-fallback path.

View File

@@ -1004,7 +1004,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
- The **Create Pull Request** modal is a floating pop-out like Plan Mission, New Task, and Automations: drag its header or resize from desktop edges/corners, while mobile keeps the full-screen dialog layout. Close it with **X**, **Cancel**, or **Escape**; stray clicks inside or outside the floating shell do not dismiss it.
- The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. The **Diff & commit preview** section starts collapsed and can be expanded on demand.
- The **Body** section includes a **Preview/Edit** toggle so authors can review the rendered markdown description before creating the PR without changing the submitted raw body text.
- AI title/body generation in the dialog is bounded to 15 seconds and is canceled if the request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever.
- AI title/body generation is bounded to 60 seconds on the server and 15 seconds in the dialog, and is canceled if the request disconnects; while it runs, the title and body fields show a skeleton loading state and are temporarily disabled, then resolve into generated content or deterministic task-based fallback content on timeout/cancel.
- Project Settings → Project Models includes optional **PR title prompt guidance** and **PR description prompt guidance** fields. Blank fields preserve the default Create PR metadata prompt; populated fields append guidance for the generated title or body sections.
- The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL.
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.

View File

@@ -179,6 +179,68 @@ FN-7170 hosts Create PR in FloatingWindow: desktop geometry belongs to the share
margin-bottom: 0;
}
.pr-create-modal__field-shell {
position: relative;
}
.pr-create-modal__field-shell > .input:disabled {
cursor: wait;
}
.pr-create-modal__metadata-skeleton {
position: absolute;
inset: var(--btn-border-width);
pointer-events: none;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--border) 70%, var(--surface));
animation: pr-create-modal-skeleton-pulse 1.4s ease-in-out infinite;
}
.pr-create-modal__metadata-skeleton--title {
inline-size: min(100%, calc(var(--space-2xl) * 7));
}
.pr-create-modal__metadata-skeleton--body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
background: color-mix(in srgb, var(--surface) 82%, var(--border));
}
.pr-create-modal__metadata-skeleton--body span {
display: block;
block-size: var(--space-md);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--border) 72%, var(--surface));
}
.pr-create-modal__metadata-skeleton--body span:nth-child(1) {
inline-size: 92%;
}
.pr-create-modal__metadata-skeleton--body span:nth-child(2) {
inline-size: 78%;
}
.pr-create-modal__metadata-skeleton--body span:nth-child(3) {
inline-size: 88%;
}
.pr-create-modal__metadata-skeleton--body span:nth-child(4) {
inline-size: 64%;
}
@keyframes pr-create-modal-skeleton-pulse {
0%, 100% {
opacity: 0.58;
}
50% {
opacity: 1;
}
}
.pr-create-template-hint {
margin: 0;
color: var(--text-muted);

View File

@@ -640,7 +640,11 @@ export function PrCreateModal({
) : null}
</section>
<section className="pr-create-modal__section">
{/**
* FNXC:PrCreateModal 2026-06-28-00:09:
* PR title/body generation must read as active description work, not as empty editable fields. Keep both fields disabled with aria-busy plus a skeleton while metadataLoading is true, then clear the affordance into AI content or the existing metadataError/manual-fallback state.
*/}
<section className="pr-create-modal__section" aria-busy={metadataLoading ? "true" : undefined}>
<div className="pr-create-modal__title-row">
<label className="pr-create-modal__label" htmlFor="pr-create-modal-title">{t("pr.titleLabel", "Title")}</label>
<div className="pr-create-modal__inline-actions">
@@ -650,10 +654,20 @@ export function PrCreateModal({
</div>
{metadataLoading ? <div className="pr-create-modal__loading pr-create-modal__section-loading"><span className="status-dot status-dot--pending" aria-hidden="true" />{t("pr.generatingTitle", "Generating AI title…")}</div> : null}
{metadataError ? <div className="form-error pr-error" role="alert"><p>{metadataError}</p></div> : null}
<input id="pr-create-modal-title" className="input" value={title} onChange={(event) => { setTitle(event.target.value); setUserEditedTitle(true); }} />
<div className="pr-create-modal__field-shell">
<input
id="pr-create-modal-title"
className="input"
value={title}
onChange={(event) => { setTitle(event.target.value); setUserEditedTitle(true); }}
disabled={metadataLoading}
aria-busy={metadataLoading ? "true" : undefined}
/>
{metadataLoading ? <div className="pr-create-modal__metadata-skeleton pr-create-modal__metadata-skeleton--title" data-testid="pr-title-loading-skeleton" aria-hidden="true" /> : null}
</div>
</section>
<section className="pr-create-modal__section">
<section className="pr-create-modal__section" aria-busy={metadataLoading ? "true" : undefined}>
<div className="pr-create-modal__title-row">
<label className="pr-create-modal__label" htmlFor="pr-create-modal-body">{t("pr.bodyLabel", "Body")}</label>
<div className="pr-create-modal__inline-actions">
@@ -675,13 +689,34 @@ export function PrCreateModal({
{/**
* FNXC:PrCreateModal 2026-06-28-00:00:
* PR authors need to preview description markdown before creating the PR. The preview is render-only, uses the shared sanitized markdown pipeline, and submission/regeneration/revert always read and write the raw `body` state.
*
* FNXC:PrCreateModal 2026-06-28-00:16:
* While AI metadata is generating, disable raw editing and show skeleton affordances even when Preview is selected so users see that the submitted body is still pending generation.
*/}
{showBodyPreview ? (
{showBodyPreview && !metadataLoading ? (
<div className="pr-create-modal__body-preview markdown-body" role="region" aria-label={t("pr.bodyPreviewLabel", "Body markdown preview")}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins}>{body}</ReactMarkdown>
</div>
) : (
<textarea id="pr-create-modal-body" className="input pr-create-modal__body-input" value={body} onChange={(event) => { setBody(event.target.value); setUserEditedBody(true); }} rows={8} />
<div className="pr-create-modal__field-shell">
<textarea
id="pr-create-modal-body"
className="input pr-create-modal__body-input"
value={body}
onChange={(event) => { setBody(event.target.value); setUserEditedBody(true); }}
rows={8}
disabled={metadataLoading}
aria-busy={metadataLoading ? "true" : undefined}
/>
{metadataLoading ? (
<div className="pr-create-modal__metadata-skeleton pr-create-modal__metadata-skeleton--body" data-testid="pr-body-loading-skeleton" aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
) : null}
</div>
)}
{templateUsed && <p className="pr-create-template-hint">{t("pr.usingTemplate", "Using <code>.github/pull_request_template.md</code>")}</p>}
</section>
@@ -790,7 +825,7 @@ export function PrCreateModal({
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>{t("actions.cancel", "Cancel")}</button>
<button type="button" className="btn btn-primary" onClick={() => void submit()} disabled={!preflight || preflightLoading || !canSubmit || !hasRequiredPrContent || submitting}>
<button type="button" className="btn btn-primary" onClick={() => void submit()} disabled={!preflight || preflightLoading || metadataLoading || !canSubmit || !hasRequiredPrContent || submitting}>
{submitting ? <RefreshCw size={14} className="spin" /> : null}
{draft ? t("pr.createDraftPr", "Create draft PR") : t("pr.createPr", "Create PR")}
</button>

View File

@@ -87,6 +87,32 @@ function stubPointerCapture(element: HTMLElement) {
Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() });
}
function expectMetadataLoadingAffordance() {
const titleInput = screen.getByLabelText(/title/i);
const bodyInput = screen.getByLabelText(/body/i);
expect(screen.getByText(/generating ai title/i)).toBeInTheDocument();
expect(screen.getByText(/generating ai body/i)).toBeInTheDocument();
expect(screen.getByTestId("pr-title-loading-skeleton")).toBeInTheDocument();
expect(screen.getByTestId("pr-body-loading-skeleton")).toBeInTheDocument();
expect(titleInput).toBeDisabled();
expect(bodyInput).toBeDisabled();
expect(titleInput).toHaveAttribute("aria-busy", "true");
expect(bodyInput).toHaveAttribute("aria-busy", "true");
}
function expectMetadataLoadingCleared() {
const titleInput = screen.getByLabelText(/title/i);
const bodyInput = screen.getByLabelText(/body/i);
expect(screen.queryByText(/generating ai title/i)).not.toBeInTheDocument();
expect(screen.queryByText(/generating ai body/i)).not.toBeInTheDocument();
expect(screen.queryByTestId("pr-title-loading-skeleton")).not.toBeInTheDocument();
expect(screen.queryByTestId("pr-body-loading-skeleton")).not.toBeInTheDocument();
expect(titleInput).toBeEnabled();
expect(bodyInput).toBeEnabled();
expect(titleInput).not.toHaveAttribute("aria-busy");
expect(bodyInput).not.toHaveAttribute("aria-busy");
}
describe("PrCreateModal", () => {
beforeEach(() => {
localStorage.clear();
@@ -408,7 +434,7 @@ describe("PrCreateModal", () => {
expect(document.querySelectorAll(".pr-create-modal__commit-row")).toHaveLength(0);
});
it("renders preflight and options before metadata resolves", async () => {
it("renders preflight and options while metadata shows a disabled loading affordance, then clears into AI content", async () => {
const metadataDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockReturnValueOnce(metadataDeferred.promise);
@@ -417,20 +443,19 @@ describe("PrCreateModal", () => {
expect(await screen.findByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expect(screen.getByText("Reviewers")).toBeInTheDocument();
expect(screen.getByText(/generating ai title/i)).toBeInTheDocument();
expectMetadataLoadingAffordance();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.change(screen.getByLabelText(/title/i), { target: { value: "Manual PR title" } });
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.change(screen.getByLabelText(/body/i), { target: { value: "Manual PR body" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.change(screen.getByPlaceholderText("Filter reviewers"), { target: { value: "rev" } });
fireEvent.click(screen.getByRole("button", { name: /reviewer 1/i }));
expect(screen.getByRole("button", { name: /remove reviewer 1/i })).toBeInTheDocument();
metadataDeferred.resolve(metadata);
expect(await screen.findByDisplayValue("Manual PR title")).toBeInTheDocument();
expect(await screen.findByDisplayValue("AI title")).toBeInTheDocument();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
expectMetadataLoadingCleared();
expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled();
});
it("keeps submit disabled while preflight is pending or failed", async () => {
@@ -448,13 +473,20 @@ describe("PrCreateModal", () => {
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
});
it("keeps metadata failure scoped so the modal remains usable", async () => {
mocks.generatePrMetadata.mockRejectedValueOnce(new Error("metadata blew up"));
it("clears metadata loading affordance into the error and manual fallback state", async () => {
const metadataDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockReturnValueOnce(metadataDeferred.promise);
renderModal();
expect(await screen.findByText("Branch pushed to remote")).toBeInTheDocument();
expectMetadataLoadingAffordance();
metadataDeferred.reject(new Error("metadata blew up"));
expect(await screen.findByText("metadata blew up")).toBeInTheDocument();
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expectMetadataLoadingCleared();
const bodyInput = screen.getByLabelText(/body/i) as HTMLTextAreaElement;
expect(bodyInput.value).toContain("## Summary");
@@ -550,7 +582,7 @@ describe("PrCreateModal", () => {
expect(screen.getByLabelText(/base branch/i)).toBeDisabled();
});
it("regenerates AI content without re-blocking preflight and options", async () => {
it("regenerates AI content without re-blocking preflight and options while restoring the loading affordance", async () => {
const regenerateDeferred = createDeferred<typeof metadata>();
mocks.generatePrMetadata.mockResolvedValueOnce(metadata).mockReturnValueOnce(regenerateDeferred.promise);
await renderModalLoaded();
@@ -560,9 +592,13 @@ describe("PrCreateModal", () => {
fireEvent.click(screen.getAllByRole("button", { name: /^regenerate$/i })[0]);
expect(screen.getByText("Branch pushed to remote")).toBeInTheDocument();
expect(screen.getByLabelText(/base branch/i)).toBeEnabled();
expect(screen.getByText(/generating ai title/i)).toBeInTheDocument();
expectMetadataLoadingAffordance();
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
expect(mocks.createPr).not.toHaveBeenCalled();
regenerateDeferred.resolve({ title: "New title", body: "New body", templateUsed: false });
await screen.findByDisplayValue("New title");
expectMetadataLoadingCleared();
fireEvent.change(screen.getByDisplayValue("New title"), { target: { value: "edited" } });
fireEvent.click(screen.getByRole("button", { name: /revert to ai version/i }));
expect(screen.getByDisplayValue("New title")).toBeInTheDocument();

View File

@@ -30,7 +30,7 @@ vi.mock("@fusion/engine", () => ({
}));
import { createFnAgent } from "@fusion/engine";
import { generatePrMetadata } from "../pr-metadata-generator.js";
import { generatePrMetadata, PR_METADATA_SYSTEM_PROMPT } from "../pr-metadata-generator.js";
function createTask(): Task {
return {
@@ -71,11 +71,7 @@ function capturedSystemPrompt(): string {
return call?.systemPrompt ?? "";
}
const BASE_PR_METADATA_SYSTEM_PROMPT = [
"Generate GitHub PR metadata.",
"Respond with strict JSON only.",
"Schema: {title, summary, changes, testing, linkedTask}",
].join("\n");
const BASE_PR_METADATA_SYSTEM_PROMPT = PR_METADATA_SYSTEM_PROMPT;
describe("generatePrMetadata", () => {
let repoRoot: string;
@@ -148,7 +144,8 @@ describe("generatePrMetadata", () => {
});
expect(capturedSystemPrompt()).toBe(BASE_PR_METADATA_SYSTEM_PROMPT);
expect(capturedSystemPrompt()).toContain("Schema: {title, summary, changes, testing, linkedTask}");
expect(capturedSystemPrompt()).toContain("\"title\": string");
expect(capturedSystemPrompt()).toContain("\"linkedTask\": string");
expect(result.title).toBe("feat: add routes");
expect(result.body).toContain("## Summary");
});
@@ -213,6 +210,39 @@ describe("generatePrMetadata", () => {
expect(result.body).toContain("## Testing");
});
it("sends the upgraded prompt contract and keeps all context inputs", async () => {
await generatePrMetadata({
task: createTask(),
repoRoot,
settings: {
titleSummarizerProvider: "anthropic",
titleSummarizerModelId: "claude-haiku",
} as never,
});
expect(PR_METADATA_SYSTEM_PROMPT).toContain("STRICT JSON ONLY");
expect(PR_METADATA_SYSTEM_PROMPT).toContain("conventional-commit style");
expect(PR_METADATA_SYSTEM_PROMPT).toContain("at most 72 characters");
expect(PR_METADATA_SYSTEM_PROMPT).toContain("commit log and diff stat first");
for (const field of ["title", "summary", "changes", "testing", "linkedTask"]) {
expect(PR_METADATA_SYSTEM_PROMPT).toContain(field);
}
expect(vi.mocked(createFnAgent)).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: PR_METADATA_SYSTEM_PROMPT,
}),
);
const contextPrompt = promptMock.mock.calls[0]?.[0] as string;
expect(contextPrompt).toContain("Ground the PR title, summary, changes, and testing in the commit log and diff stat");
expect(contextPrompt).toContain("Task ID: FN-4991");
expect(contextPrompt).toContain("Task title: Route contracts");
expect(contextPrompt).toContain("Task description: Implement route contracts");
expect(contextPrompt).toContain("Commit log (source of truth):\n\ncommit");
expect(contextPrompt).toContain("Diff stat (source of truth):\n\n1 file changed");
expect(contextPrompt).toContain("Task prompt (supporting context):\n\n# Prompt");
});
it("fills known sections when template exists and preserves unknown headings", async () => {
mkdirSync(join(repoRoot, ".github"), { recursive: true });
writeFileSync(

View File

@@ -9,6 +9,22 @@ import { createFnAgent, resolveMcpServersForStore } from "@fusion/engine";
const execAsync = promisify(execCb);
export const PR_METADATA_TIMEOUT_MS = 60_000;
/*
FNXC:PrMetadataGeneration 2026-06-28-00:09:
The Create PR AI prompt must produce operator-ready metadata grounded in the commit log and diff stat while preserving the strict JSON schema consumed by parseAiResult, buildBody, and fillTemplate. Keep the schema stable so later configurable-prompt work can reuse this default without changing downstream body assembly.
*/
export const PR_METADATA_SYSTEM_PROMPT = [
"You generate GitHub pull request metadata for Fusion tasks.",
"Respond with STRICT JSON ONLY: no markdown fences, no commentary, no prose outside the JSON object.",
"Use exactly this schema: {\"title\": string, \"summary\": string, \"changes\": string, \"testing\": string, \"linkedTask\": string}.",
"Title: concise imperative conventional-commit style, at most 72 characters, no trailing period, with a scope when the change clearly suggests one.",
"Summary: 1-3 short plain-sentence paragraphs explaining what changed and why; be specific and avoid marketing fluff.",
"Changes: a tight markdown bullet list of concrete, verifiable changes supported by the commit log or diff stat; do not speculate.",
"Testing: markdown bullets naming actual verification shown in the evidence; if no testing evidence exists, say '- Not provided.' or '- Not run (not provided).'.",
"LinkedTask: set to the task ID exactly as provided.",
"Ground every field in the commit log and diff stat first, then use the task title, task description, and task prompt only as supporting context.",
].join("\n");
export interface GeneratedPrMetadata {
title: string;
body: string;
@@ -270,11 +286,7 @@ export async function generatePrMetadata(input: {
const template = templateExists ? await raceWithAbort(readFile(templatePath, "utf8"), combinedSignal) : "";
const model = resolveTitleSummarizerSettingsModel(settings as Partial<Settings>);
const systemPrompt = [
"Generate GitHub PR metadata.",
"Respond with strict JSON only.",
"Schema: {title, summary, changes, testing, linkedTask}",
];
const systemPrompt = [PR_METADATA_SYSTEM_PROMPT];
const titleGuidance = settings.prTitlePromptInstructions?.trim();
const descriptionGuidance = settings.prDescriptionPromptInstructions?.trim();
/*
@@ -307,15 +319,20 @@ export async function generatePrMetadata(input: {
try {
const contextPrompt = [
"Source-of-truth guidance:",
"- Ground the PR title, summary, changes, and testing in the commit log and diff stat below.",
"- Use task title, task description, and task prompt only to clarify intent when the git evidence supports it.",
"- Omit speculation; when evidence for testing or a change is absent, say so instead of inventing details.",
"- Return only strict JSON matching the required schema.",
`Task ID: ${task.id}`,
`Task title: ${task.title}`,
`Task description: ${task.description ?? ""}`,
`Base branch: ${baseBranch}`,
"Commit log:",
"Commit log (source of truth):",
logOut || "(none)",
"Diff stat:",
"Diff stat (source of truth):",
diffStatOut || "(none)",
"Task prompt:",
"Task prompt (supporting context):",
promptContent || "(none)",
].join("\n\n");