diff --git a/app/(main)/task/[taskId]/qna/page.tsx b/app/(main)/task/[taskId]/qna/page.tsx index bbd51fdc..c1b1d177 100644 --- a/app/(main)/task/[taskId]/qna/page.tsx +++ b/app/(main)/task/[taskId]/qna/page.tsx @@ -153,7 +153,7 @@ export default function QnaPage() { skippedQuestions: new Set(), unansweredQuestionIds: new Set(), isGenerating: false, - attachmentIds: [], + attachments: [], attachmentUploading: false, questionGenerationStatus: null, questionGenerationError: null, @@ -169,6 +169,73 @@ export default function QnaPage() { recipeDetailsForGenerateLabel?.status, ); + const [extrasRestoredForRecipeId, setExtrasRestoredForRecipeId] = useState(null); + + // Restore saved QnA extras (additional context + attachments metadata) for this recipe. + useEffect(() => { + if (!recipeId || typeof window === "undefined") { + setExtrasRestoredForRecipeId(null); + return; + } + + setState((prev) => ({ + ...prev, + additionalContext: "", + attachments: [], + })); + + try { + const raw = localStorage.getItem(`qa_extras_${recipeId}`); + if (!raw) { + setExtrasRestoredForRecipeId(recipeId); + return; + } + const parsed = JSON.parse(raw) as { + additionalContext?: string; + attachments?: Array<{ id: string; file_name: string; mime_type: string }>; + }; + setState((prev) => ({ + ...prev, + additionalContext: + typeof parsed.additionalContext === "string" + ? parsed.additionalContext + : "", + attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [], + })); + } catch { + // ignore malformed localStorage payload + } finally { + setExtrasRestoredForRecipeId(recipeId); + } + }, [recipeId]); + + // Keep extras mirrored in localStorage so spec regenerate can sync them before triggering. + useEffect(() => { + if ( + !recipeId || + extrasRestoredForRecipeId !== recipeId || + typeof window === "undefined" + ) { + return; + } + try { + localStorage.setItem( + `qa_extras_${recipeId}`, + JSON.stringify({ + additionalContext: state.additionalContext, + attachments: state.attachments, + }) + ); + } catch { + // ignore localStorage write errors + } + }, [ + recipeId, + extrasRestoredForRecipeId, + state.additionalContext, + state.attachments, + ]); + // Fetch recipe details when recipeId is available (same priority as spec: API then localStorage) useEffect(() => { const fetchRecipeDetails = async () => { @@ -1304,11 +1371,6 @@ export default function QnaPage() { } }); - // Add additional context if provided - if (state.additionalContext.trim()) { - answersPayload["additional_context"] = state.additionalContext.trim(); - } - // Check recipe status to determine if we need to submit answers or just regenerate let shouldRegenerate = false; let recipeStatus: string | null = null; @@ -1326,9 +1388,16 @@ export default function QnaPage() { if (canSubmitAnswers) { // Submit answers (idempotent on backend) - await SpecService.submitAnswers(recipeId, answersPayload); + await SpecService.submitAnswers(recipeId, answersPayload, { + additionalContext: state.additionalContext, + attachments: state.attachments, + }); } else if (alreadyInSpecPhase) { console.log("[QnA Page] Recipe already in spec phase, skipping submitAnswers"); + await SpecService.updateQaSubmissionExtras(recipeId, { + additionalContext: state.additionalContext, + attachments: state.attachments, + }); shouldRegenerate = true; } @@ -1491,24 +1560,28 @@ export default function QnaPage() { files: File[], removedIndex?: number ) => { - const idsAfterRemove = + const refsAfterRemove = removedIndex !== undefined - ? state.attachmentIds.filter((_, i) => i !== removedIndex) - : [...state.attachmentIds]; - if (files.length <= idsAfterRemove.length) { - setState((prev) => ({ ...prev, attachmentIds: idsAfterRemove })); + ? state.attachments.filter((_, i) => i !== removedIndex) + : [...state.attachments]; + if (files.length <= refsAfterRemove.length) { + setState((prev) => ({ ...prev, attachments: refsAfterRemove })); return; } setState((prev) => ({ ...prev, attachmentUploading: true })); try { - const newIds: string[] = []; - for (let i = idsAfterRemove.length; i < files.length; i++) { + const newRefs: Array<{ id: string; file_name: string; mime_type: string }> = []; + for (let i = refsAfterRemove.length; i < files.length; i++) { const result = await MediaService.uploadFile(files[i]); - newIds.push(result.id); + newRefs.push({ + id: result.id, + file_name: result.file_name, + mime_type: result.mime_type, + }); } setState((prev) => ({ ...prev, - attachmentIds: idsAfterRemove.concat(newIds), + attachments: refsAfterRemove.concat(newRefs), attachmentUploading: false, })); } catch (err: unknown) { diff --git a/app/(main)/task/[taskId]/spec/page.tsx b/app/(main)/task/[taskId]/spec/page.tsx index 946113f2..b63c609f 100644 --- a/app/(main)/task/[taskId]/spec/page.tsx +++ b/app/(main)/task/[taskId]/spec/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef, useMemo } from "react"; +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { @@ -1014,6 +1014,49 @@ const SpecPage = () => { const showReGeneratePlanButton = planGenForLabel === "completed" || planGenForLabel === "failed"; + const persistQaExtrasFromLocalStorageIfAny = useCallback( + async (id: string) => { + if (typeof window === "undefined") return true; + try { + const raw = localStorage.getItem(`qa_extras_${id}`); + if (!raw) { + await SpecService.updateQaSubmissionExtras(id, { + additionalContext: null, + attachments: [], + }); + return true; + } + const parsed = JSON.parse(raw) as { + additionalContext?: string; + attachments?: Array<{ id: string; file_name: string; mime_type: string }>; + }; + const hasAdditionalContext = + typeof parsed.additionalContext === "string" && + parsed.additionalContext.trim().length > 0; + const hasAttachments = + Array.isArray(parsed.attachments) && parsed.attachments.length > 0; + if (!hasAdditionalContext && !hasAttachments) { + await SpecService.updateQaSubmissionExtras(id, { + additionalContext: null, + attachments: [], + }); + return true; + } + await SpecService.updateQaSubmissionExtras(id, { + additionalContext: hasAdditionalContext ? parsed.additionalContext : null, + attachments: hasAttachments ? parsed.attachments : [], + }); + return true; + } catch (err) { + console.warn("[Spec Page] Could not persist QA extras before regenerate", err); + throw err instanceof Error + ? err + : new Error("Failed to sync QA extras before regenerate"); + } + }, + [] + ); + // Persist stream timeline when spec is completed so it survives refresh useEffect(() => { if (!recipeId || status !== "COMPLETED" || !hasSpecContent) return; @@ -1281,6 +1324,11 @@ const SpecPage = () => { if (!recipeId || isRegeneratingSpec) return; setIsRegeneratingSpec(true); try { + const extrasPersisted = + await persistQaExtrasFromLocalStorageIfAny(recipeId); + if (!extrasPersisted) { + throw new Error("Failed to sync QA extras before regenerate"); + } // Call regenerate first to reset recipe state await SpecService.regenerateSpec(recipeId); // Clear transient stream state and errors but keep specProgress populated diff --git a/lib/types/spec.ts b/lib/types/spec.ts index 01f02afd..d15dfe43 100644 --- a/lib/types/spec.ts +++ b/lib/types/spec.ts @@ -193,6 +193,14 @@ export interface RecipeQuestionsResponse { /** Request for POST /api/v1/recipes/{recipe_id}/answers */ export interface SubmitRecipeAnswersRequest { answers: Record; + additional_context?: string | null; + attachments?: QaAttachmentRef[]; +} + +export interface QaAttachmentRef { + id: string; + file_name: string; + mime_type: string; } /** Response from POST /api/v1/recipes/{recipe_id}/answers */ diff --git a/services/SpecService.ts b/services/SpecService.ts index fe161d1b..f5d1c251 100644 --- a/services/SpecService.ts +++ b/services/SpecService.ts @@ -186,11 +186,26 @@ export default class SpecService { static async submitAnswers( recipeId: string, answers: Record, + extras?: { + additionalContext?: string; + attachments?: Array<{ id: string; file_name: string; mime_type: string }>; + }, ): Promise { try { console.log("[SpecService] Submitting answers for recipe:", recipeId); const headers = await getHeaders(); - const request: SubmitRecipeAnswersRequest = { answers }; + const request: SubmitRecipeAnswersRequest = { + answers, + additional_context: extras + ? extras.additionalContext?.trim() || null + : undefined, + attachments: + extras + ? extras.attachments && extras.attachments.length > 0 + ? extras.attachments + : [] + : undefined, + }; const response = await axios.post( `${this.API_BASE}/${recipeId}/answers`, request, @@ -205,6 +220,41 @@ export default class SpecService { } } + /** + * Persist only Q&A extras (additional context + attachment refs) without submitting answers. + * POST /api/v1/recipes/{recipe_id}/qa-submission + */ + static async updateQaSubmissionExtras( + recipeId: string, + extras: { + additionalContext?: string | null; + attachments?: Array<{ id: string; file_name: string; mime_type: string }>; + }, + ): Promise<{ message: string; recipe_id: string }> { + try { + const headers = await getHeaders(); + const response = await axios.post<{ message: string; recipe_id: string }>( + `${this.API_BASE}/${recipeId}/qa-submission`, + { + additional_context: extras + ? extras.additionalContext?.trim() || null + : undefined, + attachments: + extras + ? extras.attachments && extras.attachments.length > 0 + ? extras.attachments + : [] + : undefined, + }, + { headers }, + ); + return response.data; + } catch (error: any) { + const errorMessage = parseApiError(error); + throw new Error(errorMessage); + } + } + /** * Trigger spec generation (only when recipe is in ANSWERS_SUBMITTED). * To regenerate after spec is ready, use regenerateSpec() which calls .../spec/regenerate. diff --git a/types/question.ts b/types/question.ts index e42b38ae..571819e2 100644 --- a/types/question.ts +++ b/types/question.ts @@ -1,4 +1,5 @@ import type { MCQQuestion } from "@/services/QuestionService"; +import type { QaAttachmentRef } from "@/lib/types/spec"; // Re-export MCQQuestion export type { MCQQuestion }; @@ -48,8 +49,8 @@ export interface RepoPageState { /** IDs of questions that need answers (for validation highlighting) */ unansweredQuestionIds?: Set; isGenerating: boolean; - /** Attachment IDs from media upload (for additional context) */ - attachmentIds: string[]; + /** Uploaded attachment refs from media upload (for additional context) */ + attachments: QaAttachmentRef[]; attachmentUploading: boolean; /** Status from backend while questions are being generated (pending/processing/completed/failed) */ questionGenerationStatus?: QuestionGenerationStatus | null;