FN-7498: show original task prompts in Plan tab
Display the initial task prompt separately from the generated plan in task details. - Add a read-only Original prompt section above generated PROMPT.md content in task detail Plan views. - Preserve plain-text formatting and responsive wrapping for original prompts while leaving plan edit/revision controls scoped to generated prompts. - Cover modal, embedded, empty-state, CSS, docs, and release-note behavior. Files changed: .changeset/fn-7498-original-task-prompt.md | 7 ++ docs/dashboard-guide.md | 1 + .../dashboard/app/components/TaskDetailModal.css | 30 +++++ .../dashboard/app/components/TaskDetailModal.tsx | 18 +++ ...lModal.inline-editing-and-integrations.test.tsx | 127 ++++++++++++++++++++- 5 files changed, 182 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7498 Fusion-Task-Lineage: df83c73d-d731-4ba4-942b-bd93ec1b9712 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -2015,7 +2015,11 @@ The desktop table needs a token-scale min-width and non-anywhere model-name wrap
|
||||
/*
|
||||
FNXC:TaskDetailPlan 2026-06-30-00:00:
|
||||
The Plan prompt editor is a task-detail body surface, not a narrow prose inset. Scope full-width guards to the Plan prompt section so modal and embedded cards align markdown, empty fallback, inline edit, and AI revision controls without changing unrelated detail sections.
|
||||
|
||||
FNXC:TaskDetailPlan 2026-07-04-00:00:
|
||||
The Plan tab must display task.description as the immutable original operator prompt even after PROMPT.md exists. Style the original prompt as plain preserved text, not markdown, and keep the empty state unboxed so absent descriptions do not create a misleading prompt shell.
|
||||
*/
|
||||
.detail-section--original-prompt,
|
||||
.detail-section--plan-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2024,6 +2028,26 @@ The Plan prompt editor is a task-detail body surface, not a narrow prose inset.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.detail-original-prompt-text {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: var(--space-md);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-original-prompt-empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-section--plan-prompt .markdown-body,
|
||||
.detail-section--plan-prompt .detail-prompt,
|
||||
.detail-section--plan-prompt .spec-loading,
|
||||
@@ -2238,6 +2262,12 @@ The Plan prompt editor is a task-detail body surface, not a narrow prose inset.
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail-section--original-prompt,
|
||||
.detail-section--original-prompt .detail-original-prompt-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.detail-section--plan-prompt .detail-spec-edit-trigger,
|
||||
.detail-section--plan-prompt .spec-editor-actions-row,
|
||||
.detail-section--plan-prompt .spec-editor-revision-actions {
|
||||
|
||||
@@ -667,6 +667,8 @@ export function TaskDetailContent({
|
||||
: task.overlapBlockedBy === undefined ? fullDetail.overlapBlockedBy : task.overlapBlockedBy,
|
||||
} as TaskDetail)
|
||||
: ({ ...task, prompt: "" } as TaskDetail);
|
||||
const originalTaskPrompt = workingTask.description ?? "";
|
||||
const hasOriginalTaskPrompt = originalTaskPrompt.trim().length > 0;
|
||||
/*
|
||||
FNXC:WorkflowStepResults 2026-06-26-18:00:
|
||||
The detail Progress bar must show a segment for each ENABLED workflow step, not only
|
||||
@@ -4271,6 +4273,22 @@ export function TaskDetailContent({
|
||||
<div className="step-progress-empty">{t("taskDetail.progress.noSteps", "(no steps defined)")}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-section detail-section--original-prompt">
|
||||
{/**
|
||||
* FNXC:TaskDetailPlan 2026-07-04-00:00:
|
||||
* Operators need the exact prompt they entered to stay visible after planning generates PROMPT.md. Keep this section read-only and backed by task.description so PROMPT.md editing/revision controls cannot imply they mutate the original request.
|
||||
*/}
|
||||
<h4>{t("taskDetail.originalPrompt.heading", "Original prompt")}</h4>
|
||||
{hasOriginalTaskPrompt ? (
|
||||
<div className="detail-original-prompt-text" data-testid="task-detail-original-prompt">
|
||||
{originalTaskPrompt}
|
||||
</div>
|
||||
) : (
|
||||
<p className="detail-original-prompt-empty">
|
||||
{t("taskDetail.originalPrompt.empty", "No original prompt recorded.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-section detail-section--plan-prompt">
|
||||
{!isEditingSpec && (
|
||||
<div className="detail-spec-edit-trigger">
|
||||
|
||||
@@ -70,6 +70,19 @@ function getRuleBlock(css: string, selector: string): string {
|
||||
return ruleMatch?.[1] ?? "";
|
||||
}
|
||||
|
||||
function getRuleBlockFromSelectorList(css: string, selector: string): string {
|
||||
const selectorIndex = css.indexOf(selector);
|
||||
if (selectorIndex === -1) {
|
||||
return "";
|
||||
}
|
||||
const ruleStart = css.indexOf("{", selectorIndex);
|
||||
if (ruleStart === -1) {
|
||||
return "";
|
||||
}
|
||||
const ruleEnd = css.indexOf("}", ruleStart);
|
||||
return ruleEnd === -1 ? "" : css.slice(ruleStart + 1, ruleEnd);
|
||||
}
|
||||
|
||||
describe("TaskDetailModal", () => {
|
||||
describe("source issue metadata", () => {
|
||||
beforeEach(() => {
|
||||
@@ -389,6 +402,118 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plan tab original prompt", () => {
|
||||
it("shows the exact original prompt beside the generated plan in the modal", () => {
|
||||
const originalPrompt = "Exact user prompt\nwith **markdown** and `code`\n```ts\nconst answer = 42;\n```\nhttps://example.com/task";
|
||||
const generatedPrompt = "# FN-TEST\n\n## Mission\nGenerated plan";
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({
|
||||
id: "FN-TEST",
|
||||
title: "Title must not replace description",
|
||||
description: originalPrompt,
|
||||
prompt: generatedPrompt,
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Original prompt" })).toBeTruthy();
|
||||
const originalPromptNode = screen.getByTestId("task-detail-original-prompt");
|
||||
expect(originalPromptNode.textContent).toBe(originalPrompt);
|
||||
expect(originalPromptNode.innerHTML).toContain("**markdown**");
|
||||
expect(originalPromptNode.querySelector("strong, code, pre, a")).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Mission" })).toBeTruthy();
|
||||
expect(screen.getByText("Generated plan")).toBeTruthy();
|
||||
|
||||
const originalSection = container.querySelector(".detail-section--original-prompt");
|
||||
const planSection = container.querySelector(".detail-section--plan-prompt");
|
||||
expect(originalSection?.contains(screen.getByText("Original prompt"))).toBe(true);
|
||||
expect(planSection?.contains(screen.getByRole("button", { name: "Edit" }))).toBe(true);
|
||||
expect(originalSection?.contains(screen.getByRole("button", { name: "Edit" }))).toBe(false);
|
||||
expect(originalPromptNode.textContent).not.toBe("Title must not replace description");
|
||||
});
|
||||
|
||||
it("renders a non-boxed empty fallback without hiding the generated plan", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({
|
||||
id: "FN-EMPTY",
|
||||
description: " \n\t ",
|
||||
prompt: "# FN-EMPTY\n\n## Mission\nGenerated plan still visible",
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No original prompt recorded.")).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-detail-original-prompt")).toBeNull();
|
||||
expect(container.querySelector(".detail-section--original-prompt .detail-original-prompt-text")).toBeNull();
|
||||
expect(screen.getByText("Generated plan still visible")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the same original prompt section in embedded task detail content", () => {
|
||||
const originalPrompt = "Embedded prompt\nwith a second line";
|
||||
const { container } = render(
|
||||
<TaskDetailContent
|
||||
embedded
|
||||
initialTab="definition"
|
||||
task={makeTask({
|
||||
id: "FN-EMBED",
|
||||
description: originalPrompt,
|
||||
prompt: "# FN-EMBED\n\n## Mission\nEmbedded generated plan",
|
||||
})}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".task-detail-content--embedded")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-detail-original-prompt").textContent).toBe(originalPrompt);
|
||||
expect(screen.getByText("Embedded generated plan")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies wrapping and mobile CSS contracts to the original prompt section", () => {
|
||||
const css = readDashboardStylesSource();
|
||||
const promptTextBlock = getRuleBlock(css, ".detail-original-prompt-text");
|
||||
const emptyBlock = getRuleBlock(css, ".detail-original-prompt-empty");
|
||||
const mobileBlock = getMediaBlocks(css, "@media (max-width: 768px)").find((block) =>
|
||||
block.includes(".detail-section--original-prompt"),
|
||||
) ?? "";
|
||||
|
||||
expect(css).toContain(".detail-section--original-prompt,\n.detail-section--plan-prompt");
|
||||
expect(promptTextBlock).toContain("box-sizing: border-box;");
|
||||
expect(promptTextBlock).toContain("width: 100%;");
|
||||
expect(promptTextBlock).toContain("min-width: 0;");
|
||||
expect(promptTextBlock).toContain("max-width: 100%;");
|
||||
expect(promptTextBlock).toContain("padding: var(--space-md);");
|
||||
expect(promptTextBlock).toContain("border: var(--btn-border-width) solid var(--border);");
|
||||
expect(promptTextBlock).toContain("white-space: pre-wrap;");
|
||||
expect(promptTextBlock).toContain("overflow-wrap: anywhere;");
|
||||
expect(promptTextBlock).not.toMatch(/#[0-9a-fA-F]{3,8}|rgb\(/);
|
||||
expect(emptyBlock).toContain("margin: 0;");
|
||||
expect(emptyBlock).toContain("color: var(--text-muted);");
|
||||
expect(mobileBlock).toContain(".detail-section--original-prompt,");
|
||||
expect(mobileBlock).toContain(".detail-section--original-prompt .detail-original-prompt-text");
|
||||
expect(mobileBlock).toContain("max-width: 100%;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline editing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -2810,7 +2935,7 @@ describe("TaskDetailModal", () => {
|
||||
const mobileCss = getMediaBlocks(css, "@media (max-width: 768px)").join("\n");
|
||||
const enableRule = getRuleBlock(mobileCss, ".detail-github-tracking-enable");
|
||||
const headerRule = getRuleBlock(mobileCss, ".detail-source-header");
|
||||
const githubSummaryRule = getRuleBlock(mobileCss, ".detail-github-tracking-section .detail-source-summary");
|
||||
const githubSummaryRule = getRuleBlockFromSelectorList(mobileCss, ".detail-github-tracking-section .detail-source-summary");
|
||||
const sourceSummaryRule = getRuleBlock(mobileCss, ".detail-source-section .detail-source-summary");
|
||||
|
||||
expect(mobileCss).toBeTruthy();
|
||||
|
||||
Reference in New Issue
Block a user