From 728eb1adaf55cdb26ffbc1434509413874287c27 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 20:30:28 -0700 Subject: [PATCH] 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) --- docs/dashboard-guide.md | 3 + .../app/components/PlanningModeModal.css | 33 +++++ .../app/components/PlanningModeModal.tsx | 116 +++++++++++---- .../PlanningModeModal.planning-flow.test.tsx | 139 ++++++++++++++++++ 4 files changed, 266 insertions(+), 25 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5ef69ee138..9daf1c97c9 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -460,6 +460,9 @@ When a Planning session is awaiting your input, use its **Background Tasks** foo 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. + +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. + diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 305d75ae29..21ee20b030 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -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); } diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 9c7b085073..c5dd01378d 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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({ type: "initial" }); const [error, setError] = useState(null); const [responseHistory, setResponseHistory] = useState([]); @@ -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 | 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; + } + } 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 ? : } {isRetrying ? t("planning.retrying", "Retrying...") : t("common.retry", "Retry")} + {activePlanPrompt.trim() && ( + + )} @@ -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} /> )} @@ -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; } -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
-
- {[1, 2, 3].map((step) => ( -
- ))} +
+
+ {[1, 2, 3].map((step) => ( +
+ ))} +
+ {onCopyPlanPrompt && ( + + )}
{t("planning.questionProgress", "Question {{progress}} of ~3", { progress })}
diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 766458c623..8594990969 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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([