FN-7305: add prompt file access to task plans

Expose task plans as the user-facing Definition tab label and add a direct PROMPT.md editor entry point.

- Rename the task detail Definition tab label to Plan while preserving the existing tab id.
- Add an Open PROMPT.md action that uses the file browser provider for project task plan files.
- Adjust Plan action row layout for multiple buttons and mobile wrapping.
- Cover the Plan label and PROMPT.md action with task detail modal tests.
- Add a published package changeset for the dashboard task plan improvement.

Files changed:
 .changeset/tidy-plan-prompt-editor.md              |  7 +++
 .../dashboard/app/components/TaskDetailModal.css   | 14 ++++++
 .../dashboard/app/components/TaskDetailModal.tsx   | 20 +++++++-
 .../TaskDetailModal.definition-actions.test.tsx    | 55 +++++++++++++++++-----
 4 files changed, 83 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-7305

Fusion-Task-Lineage: afe732f9-e9a1-409f-9b09-ae1deccbe3be

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 14:37:51 -07:00
parent 1bf86d15ea
commit da15c1ceee
4 changed files with 83 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Rename the task Definition tab to Plan and add a PROMPT.md editor action.
category: feature
dev: Dashboard task details now open the current task's PROMPT.md via FileBrowserProvider.

View File

@@ -851,9 +851,23 @@ Expanded chat must also cover the task metadata row — priority, execution mode
.detail-spec-edit-trigger {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
@media (max-width: 768px) {
.detail-spec-edit-trigger {
align-items: stretch;
}
.detail-spec-edit-trigger .btn {
flex: 1 1 auto;
}
}
.detail-attachments-grid {
display: flex;
flex-wrap: wrap;

View File

@@ -66,6 +66,7 @@ import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogE
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu";
import { useFileBrowser } from "../context/FileBrowserContext";
const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/;
const EMPTY_MARKDOWN_CHILD_SEPARATOR = "";
@@ -513,6 +514,7 @@ export function TaskDetailContent({
}: TaskDetailContentProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
const fileBrowser = useFileBrowser();
const [activeTab, setActiveTab] = useState<TabId>(() => resolveDefaultTab(initialTab, task.column));
const [chatExpanded, setChatExpanded] = useState(false);
@@ -607,6 +609,9 @@ export function TaskDetailContent({
() => getUnifiedTaskProgress(workingTask),
[workingTask.steps, workingTask.enabledWorkflowSteps, workingTask.workflowStepResults],
);
const openPromptFile = useCallback(() => {
fileBrowser?.openFile(`.fusion/tasks/${workingTask.id}/PROMPT.md`, { workspace: "project" });
}, [fileBrowser, workingTask.id]);
const canRetryTask =
task.status === "failed" ||
task.status === "stuck-killed" ||
@@ -3155,7 +3160,7 @@ export function TaskDetailContent({
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("definition")}
>
{t("taskDetail.tabs.definition", "Definition")}
{t("taskDetail.tabs.definition", "Plan")}
</button>
<button
className={`detail-tab${activeTab === "logs" ? " detail-tab-active" : ""}`}
@@ -3775,6 +3780,19 @@ export function TaskDetailContent({
<div className="detail-section">
{!isEditingSpec && (
<div className="detail-spec-edit-trigger">
{/*
FNXC:TaskDetailPlan 2026-06-30-00:00:
The Plan tab keeps the internal definition route for stable links, while exposing a direct PROMPT.md editor action so operators can comment on the executable task plan file without replacing the inline AI revision flow.
*/}
{fileBrowser && (
<button
className="btn btn-sm"
onClick={openPromptFile}
title={t("taskDetail.spec.openPromptTitle", "Open this task's PROMPT.md in the file editor")}
>
{t("taskDetail.spec.openPromptBtn", "Open PROMPT.md")}
</button>
)}
<button className="btn btn-sm" onClick={enterSpecEditMode}>
{t("taskDetail.spec.editBtn", "Edit")}
</button>

View File

@@ -15,13 +15,14 @@ import {
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
import { readBoardWorkflowSelection, removeBoardWorkflowSelection, writeBoardWorkflowSelection } from "../../utils/boardWorkflowSelection";
setupTaskDetailModalHooks();
describe("TaskDetailModal", () => {
describe("Definition tab edit mode", () => {
it("shows Edit button in Definition tab", () => {
describe("Plan tab edit mode", () => {
it("shows Edit button in Plan tab", () => {
render(
<TaskDetailModal
task={makeTask({ prompt: "# Test\n\nSpec content." })}
@@ -35,9 +36,39 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Definition" })).toBeNull();
expect(screen.getByText("Edit")).toBeTruthy();
});
it("opens the task PROMPT.md file from the near-top Plan action", async () => {
const user = userEvent.setup();
const openFile = vi.fn();
const { container } = render(
<FileBrowserProvider openFile={openFile}>
<TaskDetailModal
task={makeTask({ id: "FN-099", prompt: "# Test\n\nSpec content." })}
initialTab="definition"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>
</FileBrowserProvider>,
);
const actionRow = container.querySelector(".detail-spec-edit-trigger");
expect(actionRow).toBeTruthy();
const promptButton = screen.getByRole("button", { name: "Open PROMPT.md" });
expect(actionRow?.contains(promptButton)).toBe(true);
await user.click(promptButton);
expect(openFile).toHaveBeenCalledWith(".fusion/tasks/FN-099/PROMPT.md", { workspace: "project" });
});
it("clicking Edit shows textarea with current prompt content", () => {
const { container } = render(
<TaskDetailModal
@@ -191,11 +222,11 @@ describe("TaskDetailModal", () => {
);
// In-progress tasks show exactly 11 tabs:
// Chat, Definition, Logs, Changes, Review, Comments, Artifacts, Model, Workflow, Stats, Routing
// Chat, Plan, Logs, Changes, Review, Comments, Artifacts, Model, Workflow, Stats, Routing
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Chat");
expect(tabs[1].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Plan");
expect(tabs[2].textContent).toBe("Logs");
expect(tabs[3].textContent).toBe("Changes");
expect(tabs[4].textContent).toBe("Review");
@@ -227,7 +258,7 @@ describe("TaskDetailModal", () => {
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Chat");
expect(tabs[1].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Plan");
expect(tabs[2].textContent).toBe("Logs");
expect(tabs[3].textContent).toBe("Changes");
expect(tabs[4].textContent).toBe("Review");
@@ -256,12 +287,12 @@ describe("TaskDetailModal", () => {
/>,
);
// Done task with commit SHA: Summary, Chat, Definition, Logs, Changes, Review, Comments, Artifacts, Model, Workflow, Stats, Routing (12 tabs, no Commits)
// Done task with commit SHA: Summary, Chat, Plan, Logs, Changes, Review, Comments, Artifacts, Model, Workflow, Stats, Routing (12 tabs, no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(12);
expect(tabs[0].textContent).toBe("Summary");
expect(tabs[1].textContent).toBe("Chat");
expect(tabs[2].textContent).toBe("Definition");
expect(tabs[2].textContent).toBe("Plan");
expect(tabs[3].textContent).toBe("Logs");
expect(tabs[4].textContent).toBe("Changes");
expect(tabs[5].textContent).toBe("Review");
@@ -298,7 +329,7 @@ describe("TaskDetailModal", () => {
expect(tabs.length).toBe(12);
expect(tabs[0].textContent).toBe("Summary");
expect(tabs[1].textContent).toBe("Chat");
expect(tabs[2].textContent).toBe("Definition");
expect(tabs[2].textContent).toBe("Plan");
expect(tabs[3].textContent).toBe("Logs");
expect(tabs[4].textContent).toBe("Changes");
expect(tabs[5].textContent).toBe("Review");
@@ -327,9 +358,9 @@ describe("TaskDetailModal", () => {
);
const triageTabs = triageContainer.querySelectorAll(".detail-tab");
expect(triageTabs.length).toBe(10); // Chat, Definition, Logs, Review, Comments, Artifacts, Model, Workflow, Stats, Routing
expect(triageTabs.length).toBe(10); // Chat, Plan, Logs, Review, Comments, Artifacts, Model, Workflow, Stats, Routing
expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([
"Chat", "Definition", "Logs", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing",
"Chat", "Plan", "Logs", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing",
]);
const { container: todoContainer } = render(
@@ -346,9 +377,9 @@ describe("TaskDetailModal", () => {
);
const todoTabs = todoContainer.querySelectorAll(".detail-tab");
expect(todoTabs.length).toBe(10); // Chat, Definition, Logs, Review, Comments, Artifacts, Model, Workflow, Stats, Routing
expect(todoTabs.length).toBe(10); // Chat, Plan, Logs, Review, Comments, Artifacts, Model, Workflow, Stats, Routing
expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([
"Chat", "Definition", "Logs", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing",
"Chat", "Plan", "Logs", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing",
]);
});