FN-8003: add planning prompt recovery copy action

Preserve original planning prompts for recovery from stalled or off-track interviews.

- Add Copy prompt actions to active interview and error recovery surfaces.
- Restore original prompts for resumable sessions and provide clipboard feedback.
- Cover prompt copying across active, error, resumed, and absent-prompt states.

Files changed:
 docs/dashboard-guide.md                            |   3 +
 .../dashboard/app/components/PlanningModeModal.css |  33 +++++
 .../dashboard/app/components/PlanningModeModal.tsx | 116 +++++++++++++----
 .../PlanningModeModal.planning-flow.test.tsx       | 139 +++++++++++++++++++++
 4 files changed, 266 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-8003

Fusion-Task-Lineage: 11c0ec9d-290b-4793-9571-002d4b429c5e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 20:30:28 -07:00
parent e3f98253cc
commit 728eb1adaf
4 changed files with 266 additions and 25 deletions

View File

@@ -460,6 +460,9 @@ When a Planning session is awaiting your input, use its **Background Tasks** foo
<!-- FNXC:PlanningRetry 2026-07-13-00:00: If Planning AI generation stalls and the server persists a terminal generation error, Planning Mode should auto-retry the same session up to three times before showing the permanent Retry/Dismiss error panel; any question or summary progress resets that budget. -->
When Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget.
<!-- FNXC:Planning 2026-07-15-00:00: FN-8003 preserves the user’s original planning idea across error and mid-interview recovery surfaces. -->
Use **Copy prompt** in the error panel or an active interview question to copy the original “What do you want to build?” text, then paste it into **New session** to restart cleanly.
<!-- FNXC:PlanningModeDeepeningCheckpoint 2026-07-02-12:18: Planning Mode must pause before every final summary at a mandatory "Would you like to go deeper?" checkpoint so users can request inferred follow-up themes, enter a custom topic, or proceed without deepening. -->
<!-- FNXC:PlanningMode 2026-07-05-00:25: The planning AI now proposes plan-specific deepening themes (deepeningThemes on the completion payload) so this checkpoint surfaces topics tailored to the user's actual plan, including angles they had not anticipated, instead of a fixed generic set. The regex-derived generic themes remain the fallback whenever the AI supplies none (FN-7616 / issue #1912). -->

View File

@@ -927,6 +927,25 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
flex-shrink: 0;
}
/*
FNXC:Planning 2026-07-15-00:00:
FN-8003 keeps Copy prompt in the question progress header so an interview can be restarted from its original idea without leaving the active question. The flexible progress bar preserves room for the recovery action at narrow desktop widths.
*/
.planning-progress-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.planning-progress-header .planning-progress-bar {
flex: 1;
min-width: 0;
}
.planning-copy-prompt-btn {
flex-shrink: 0;
}
.planning-progress-bar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1749,6 +1768,19 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
font-size: 16px;
}
/*
FNXC:Planning 2026-07-15-00:00:
FN-8003 gives the recovery action its own mobile row so Copy prompt remains tappable without squeezing the interview progress bar or error actions.
*/
.planning-progress-header {
align-items: stretch;
flex-direction: column;
}
.planning-copy-prompt-btn {
align-self: flex-start;
}
/* Progress bar compact on narrow screens */
.planning-progress-bar {
gap: var(--space-xs);
@@ -1850,6 +1882,7 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
}
.ai-error-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}

View File

@@ -51,7 +51,7 @@ import {
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore, Copy } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { OnboardingDisclosure } from "./OnboardingDisclosure";
@@ -61,6 +61,7 @@ import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
import { useToast } from "../hooks/useToast";
import { copyTextToClipboard } from "../utils/copyToClipboard";
const WARNING_ICON = "⚠️";
@@ -303,6 +304,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// Escape-to-close behavior (the back-stack/onClose path), so escapeEnabled is deliberately not wired below.
const { isEmbedded, scrollLockEnabled, resizePersistEnabled } = useEmbeddedPresentation(presentation);
const [initialPlan, setInitialPlan] = useState("");
/*
FNXC:Planning 2026-07-15-00:00:
FN-8003 keeps the started prompt separate from the editable composer so users can recover the original idea when an interview errors or drifts off track. The composer may be reset or reused, but this value belongs only to the active session.
*/
const [activePlanPrompt, setActivePlanPrompt] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
@@ -700,6 +706,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!options?.preserveInitialPlan) {
setInitialPlan("");
}
setActivePlanPrompt("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
@@ -1069,10 +1076,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
startPlanningAutoRetryRef.current = startPlanningAutoRetry;
/*
FNXC:Planning 2026-07-15-00:00:
FN-8003 exposes recovery only through the shared clipboard helper so Copy prompt works on secure desktop origins and non-secure LAN/mobile origins alike. Do not replace this with navigator.clipboard directly.
*/
const handleCopyPlanPrompt = useCallback(async () => {
if (!activePlanPrompt.trim()) {
return;
}
const copied = await copyTextToClipboard(activePlanPrompt);
addToast(
copied
? t("planning.copyPromptSuccess", "Prompt copied to clipboard")
: t("planning.copyPromptFailure", "Failed to copy prompt"),
copied ? "success" : "error",
);
}, [activePlanPrompt, addToast, t]);
const handleStartPlanning = useCallback(async (planOverride?: string) => {
const plan = planOverride ?? initialPlan;
if (!plan.trim()) return;
const startedPlan = plan.trim();
if (!startedPlan) return;
setActivePlanPrompt(startedPlan);
setError(null);
setStreamingOutput("");
setConversationHistory([]);
@@ -1096,7 +1123,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const draftSessionId = draftSessionIdRef.current;
const { sessionId } = await startPlanningStreaming(
plan.trim(),
startedPlan,
projectId,
modelOverride,
{
@@ -1197,11 +1224,22 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// new-session view rather than surfacing a scary error banner.
setSelectedSessionId(null);
setMobileShowDetail(false);
setActivePlanPrompt("");
setView({ type: "initial" });
return;
}
currentSessionIdRef.current = sessionId;
let inputPayload: Record<string, unknown> | null = null;
try {
const parsedPayload: unknown = session.inputPayload ? JSON.parse(session.inputPayload) : null;
if (parsedPayload && typeof parsedPayload === "object" && !Array.isArray(parsedPayload)) {
inputPayload = parsedPayload as Record<string, unknown>;
}
} catch {
// An unavailable payload cannot provide a safe copy target.
}
setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : "");
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
@@ -1236,20 +1274,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
let savedProvider: string | undefined;
let savedModelId: string | undefined;
let savedThinkingLevel: ThinkingLevel | "" = "";
try {
const payload = session.inputPayload ? JSON.parse(session.inputPayload) : null;
if (payload && typeof payload.initialPlan === "string") {
savedPlan = payload.initialPlan;
}
if (payload && typeof payload.modelProvider === "string" && typeof payload.modelId === "string") {
savedProvider = payload.modelProvider;
savedModelId = payload.modelId;
}
if (payload && THINKING_LEVELS.includes(payload.thinkingLevel as ThinkingLevel)) {
savedThinkingLevel = payload.thinkingLevel;
}
} catch {
// Fall through with empty text; the row will remain editable.
if (typeof inputPayload?.initialPlan === "string") {
savedPlan = inputPayload.initialPlan;
}
if (typeof inputPayload?.modelProvider === "string" && typeof inputPayload.modelId === "string") {
savedProvider = inputPayload.modelProvider;
savedModelId = inputPayload.modelId;
}
if (inputPayload && THINKING_LEVELS.includes(inputPayload.thinkingLevel as ThinkingLevel)) {
savedThinkingLevel = inputPayload.thinkingLevel as ThinkingLevel;
}
setInitialPlan(savedPlan);
setPlanningModelProvider(savedProvider);
@@ -1299,6 +1332,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
} catch (err) {
currentSessionIdRef.current = sessionId;
setActivePlanPrompt("");
setError(null);
setView({
type: "error",
@@ -2461,6 +2495,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span className="icon-ml-6">{isRetrying ? t("planning.retrying", "Retrying...") : t("common.retry", "Retry")}</span>
</button>
{activePlanPrompt.trim() && (
<button
className="btn"
type="button"
onClick={() => void handleCopyPlanPrompt()}
aria-label={t("planning.copyPromptLabel", "Copy original prompt to clipboard")}
title={t("planning.copyPromptLabel", "Copy original prompt to clipboard")}
>
<Copy size={14} />
<span className="icon-ml-6">{t("planning.copyPrompt", "Copy prompt")}</span>
</button>
)}
<button className="btn" onClick={handleClose} disabled={isRetrying}>{t("planning.dismiss", "Dismiss")}</button>
</div>
</div>
@@ -2477,6 +2523,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onSubmit={handleSubmitResponse}
onBack={responseHistory.length > 0 ? handleBack : undefined}
isBackPending={isBackPending}
onCopyPlanPrompt={activePlanPrompt.trim() ? handleCopyPlanPrompt : undefined}
/>
</div>
)}
@@ -2534,6 +2581,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
);
}
/*
FNXC:Planning 2026-07-15-00:00:
FN-8003 places Copy prompt inside QuestionForm's progress header because the recovery control is part of the active interview panel, keeping it visible with the question on desktop and mobile rather than separating related interview actions at the modal level.
*/
interface QuestionFormProps {
question: PlanningQuestion;
progress: number;
@@ -2541,9 +2592,10 @@ interface QuestionFormProps {
onSubmit: (responses: QuestionResponse) => void;
onBack?: () => void;
isBackPending?: boolean;
onCopyPlanPrompt?: () => void | Promise<void>;
}
function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmit, onBack, isBackPending = false }: QuestionFormProps) {
function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmit, onBack, isBackPending = false, onCopyPlanPrompt }: QuestionFormProps) {
const { t } = useTranslation("app");
const question = normalizeQuestionOptions(rawQuestion);
const questionOptions = question.options ?? [];
@@ -2663,13 +2715,27 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
<div className="planning-question-panel">
<div className="planning-progress">
<div className="planning-progress-bar">
{[1, 2, 3].map((step) => (
<div
key={step}
className={`planning-progress-step ${step <= progress ? "active" : ""}`}
/>
))}
<div className="planning-progress-header">
<div className="planning-progress-bar">
{[1, 2, 3].map((step) => (
<div
key={step}
className={`planning-progress-step ${step <= progress ? "active" : ""}`}
/>
))}
</div>
{onCopyPlanPrompt && (
<button
className="btn btn-sm planning-copy-prompt-btn"
type="button"
onClick={() => void onCopyPlanPrompt()}
aria-label={t("planning.copyPromptLabel", "Copy original prompt to clipboard")}
title={t("planning.copyPromptLabel", "Copy original prompt to clipboard")}
>
<Copy size={14} />
<span className="icon-ml-6">{t("planning.copyPrompt", "Copy prompt")}</span>
</button>
)}
</div>
<span className="planning-progress-text">{t("planning.questionProgress", "Question {{progress}} of ~3", { progress })}</span>
</div>

View File

@@ -64,6 +64,7 @@ import {
} from "./PlanningModeModal.test-helpers";
const mockAddToast = vi.fn();
const mockCopyTextToClipboard = vi.fn();
vi.mock("../../hooks/useToast", () => ({
useToast: () => ({
@@ -113,6 +114,10 @@ vi.mock("../../api", () => ({
deleteAiSession: (...args: any[]) => mockDeleteAiSession(...args),
}));
vi.mock("../../utils/copyToClipboard", () => ({
copyTextToClipboard: (...args: unknown[]) => mockCopyTextToClipboard(...args),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
@@ -137,6 +142,8 @@ describe("PlanningModeModal", () => {
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockAddToast.mockReset();
mockCopyTextToClipboard.mockReset();
mockCopyTextToClipboard.mockResolvedValue(true);
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource as any);
window.sessionStorage.clear();
@@ -3924,6 +3931,138 @@ describe("PlanningModeModal", () => {
});
});
describe("copy original prompt recovery", () => {
it("copies the fresh original prompt from an active interview", async () => {
const originalPrompt = "Build a recovery flow\nwith a restart path";
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: originalPrompt },
});
fireEvent.click(screen.getByRole("button", { name: "Start Planning" }));
const copyButton = await screen.findByRole("button", { name: /copy original prompt/i });
fireEvent.click(copyButton);
await waitFor(() => {
expect(mockCopyTextToClipboard).toHaveBeenCalledWith(originalPrompt);
expect(mockAddToast).toHaveBeenCalledWith("Prompt copied to clipboard", "success");
});
});
it("copies the fresh original prompt after the interview errors", async () => {
const streamHandlers: any[] = [];
const originalPrompt = "Build an interview that can recover";
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers.push(handlers);
return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: originalPrompt },
});
fireEvent.click(screen.getByRole("button", { name: "Start Planning" }));
await waitFor(() => expect(streamHandlers).toHaveLength(1));
for (let index = 0; index < 4; index += 1) {
await act(async () => {
streamHandlers[index].onError?.("Planning provider failed");
});
}
const copyButton = await screen.findByRole("button", { name: /copy original prompt/i });
fireEvent.click(copyButton);
await waitFor(() => expect(mockCopyTextToClipboard).toHaveBeenCalledWith(originalPrompt));
});
it("restores a resumable awaiting-input session prompt before copying", async () => {
const originalPrompt = "Resume awaiting-input prompt";
mockFetchAiSession.mockResolvedValueOnce({
id: "copy-awaiting-input",
type: "planning",
status: "awaiting_input",
title: "Recoverable session",
inputPayload: JSON.stringify({ initialPlan: originalPrompt }),
conversationHistory: "[]",
currentQuestion: JSON.stringify(mockQuestion),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="copy-awaiting-input"
/>,
);
const copyButton = await screen.findByRole("button", { name: /copy original prompt/i });
fireEvent.click(copyButton);
await waitFor(() => expect(mockCopyTextToClipboard).toHaveBeenCalledWith(originalPrompt));
});
it.each(["desktop", "mobile"] as const)("hides Copy prompt without a persisted prompt on %s", async (viewportMode) => {
mockViewport(viewportMode);
mockFetchAiSession.mockResolvedValueOnce({
id: `copy-empty-${viewportMode}`,
type: "planning",
status: "awaiting_input",
title: "Missing prompt",
inputPayload: "{}",
conversationHistory: "[]",
currentQuestion: JSON.stringify(mockQuestion),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId={`copy-empty-${viewportMode}`}
/>,
);
await screen.findByText(mockQuestion.question);
expect(screen.queryByRole("button", { name: /copy original prompt/i })).toBeNull();
expect(mockCopyTextToClipboard).not.toHaveBeenCalled();
});
});
describe("Session history", () => {
it("renders only one row when fetch and SSE deliver the same session id", async () => {
mockFetchAiSessions.mockResolvedValueOnce([