Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 89 additions & 16 deletions app/(main)/task/[taskId]/qna/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export default function QnaPage() {
skippedQuestions: new Set(),
unansweredQuestionIds: new Set(),
isGenerating: false,
attachmentIds: [],
attachments: [],
attachmentUploading: false,
questionGenerationStatus: null,
questionGenerationError: null,
Expand All @@ -169,6 +169,73 @@ export default function QnaPage() {
recipeDetailsForGenerateLabel?.status,
);

const [extrasRestoredForRecipeId, setExtrasRestoredForRecipeId] = useState<string | null>(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 () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
50 changes: 49 additions & 1 deletion app/(main)/task/[taskId]/spec/page.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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");
}
Comment thread
shmbhvi101 marked this conversation as resolved.
},
[]
);

// Persist stream timeline when spec is completed so it survives refresh
useEffect(() => {
if (!recipeId || status !== "COMPLETED" || !hasSpecContent) return;
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/types/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ export interface RecipeQuestionsResponse {
/** Request for POST /api/v1/recipes/{recipe_id}/answers */
export interface SubmitRecipeAnswersRequest {
answers: Record<string, string>;
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 */
Expand Down
52 changes: 51 additions & 1 deletion services/SpecService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,26 @@ export default class SpecService {
static async submitAnswers(
recipeId: string,
answers: Record<string, string>,
extras?: {
additionalContext?: string;
attachments?: Array<{ id: string; file_name: string; mime_type: string }>;
},
): Promise<SubmitRecipeAnswersResponse> {
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,
};
Comment thread
shmbhvi101 marked this conversation as resolved.
const response = await axios.post<SubmitRecipeAnswersResponse>(
`${this.API_BASE}/${recipeId}/answers`,
request,
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions types/question.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MCQQuestion } from "@/services/QuestionService";
import type { QaAttachmentRef } from "@/lib/types/spec";

// Re-export MCQQuestion
export type { MCQQuestion };
Expand Down Expand Up @@ -48,8 +49,8 @@ export interface RepoPageState {
/** IDs of questions that need answers (for validation highlighting) */
unansweredQuestionIds?: Set<string>;
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;
Expand Down
Loading