From 2fe1b996bfedcf3b56f45a9dc692bf3be95570d4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 23:01:44 -0700 Subject: [PATCH] FN-8400: rework Planning Mode into a guided three-pane flow Make planning interviews easier to navigate while keeping the evolving plan and validation actions accessible. - Show answered questions in an editable navigation pane and keep one clear next-question action. - Keep the generated plan and validation controls visible throughout planning and recovery. - Add server validation and localized copy for session title updates. Files changed: .changeset/fn-8400-planning-mode-ui.md | 7 + MOBILE.md | 2 +- docs/dashboard-guide.md | 6 +- packages/dashboard/app/api/legacy.ts | 12 +- .../dashboard/app/components/PlanningModeModal.css | 112 +++-- .../dashboard/app/components/PlanningModeModal.tsx | 408 +++++++++++-------- .../PlanningModeModal.planning-flow.test.tsx | 453 ++++++++------------- .../PlanningModeModal.ui-interactions.test.tsx | 4 +- .../src/__tests__/routes-planning.test.ts | 50 +++ packages/dashboard/src/planning.ts | 21 +- .../src/routes/register-planning-subtask-routes.ts | 26 ++ packages/i18n/locales/en/app.json | 7 +- packages/i18n/locales/es/app.json | 7 +- packages/i18n/locales/fr/app.json | 7 +- packages/i18n/locales/ko/app.json | 7 +- packages/i18n/locales/zh-CN/app.json | 7 +- packages/i18n/locales/zh-TW/app.json | 7 +- packages/i18n/src/resources.d.ts | 5 + 18 files changed, 620 insertions(+), 528 deletions(-) Fusion-Task-Id: FN-8400 Fusion-Task-Lineage: 032cf0ad-134d-4680-878f-b870eea23cc9 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8400-planning-mode-ui.md | 7 + MOBILE.md | 2 +- docs/dashboard-guide.md | 6 +- packages/dashboard/app/api/legacy.ts | 12 +- .../app/components/PlanningModeModal.css | 138 +++--- .../app/components/PlanningModeModal.tsx | 418 +++++++++------- .../PlanningModeModal.planning-flow.test.tsx | 453 +++++++----------- ...PlanningModeModal.ui-interactions.test.tsx | 4 +- .../src/__tests__/routes-planning.test.ts | 50 ++ packages/dashboard/src/planning.ts | 21 +- .../register-planning-subtask-routes.ts | 26 + packages/i18n/locales/en/app.json | 7 +- packages/i18n/locales/es/app.json | 7 +- packages/i18n/locales/fr/app.json | 7 +- packages/i18n/locales/ko/app.json | 7 +- packages/i18n/locales/zh-CN/app.json | 7 +- packages/i18n/locales/zh-TW/app.json | 7 +- packages/i18n/src/resources.d.ts | 5 + 18 files changed, 638 insertions(+), 546 deletions(-) create mode 100644 .changeset/fn-8400-planning-mode-ui.md diff --git a/.changeset/fn-8400-planning-mode-ui.md b/.changeset/fn-8400-planning-mode-ui.md new file mode 100644 index 0000000000..6887a6e928 --- /dev/null +++ b/.changeset/fn-8400-planning-mode-ui.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Rebuild Planning Mode into a three-pane interview with always-visible plan and Validate. +category: feature +dev: Depends on FN-8341 validate/edit-and-branch contract; adds PATCH /planning/:sessionId/title for rename. diff --git a/MOBILE.md b/MOBILE.md index 7b4d565ce0..e79da0e2b0 100644 --- a/MOBILE.md +++ b/MOBILE.md @@ -101,7 +101,7 @@ gesture delivery enabled across `cap sync` regenerations. ### Planning Mode -Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. On desktop/tablet the split view is unaffected. Once sessions are saved, mobile shows the session list as usual and the user can navigate between list and detail panes. +Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. On desktop/tablet the split view is unaffected. Once sessions are saved, mobile shows the session list as usual and the user can navigate between list and detail panes; active interviews keep the running plan reachable beside the answered-question history. ### Chat and Quick Chat mobile scroll/readability behavior diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7f0ad3c6ca..37d82fba58 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -525,11 +525,11 @@ When a Planning session needs your input or needs attention, open the docked Pla When an active 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. Reopening or reloading a saved Planning session restores its saved question, summary, thinking, or error without starting another generation; choose **Retry** explicitly from a restored error panel if you want to run it again. - -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. + +Use **New session** to restart planning with a different idea. -Planning Mode keeps asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview; the AI never ends it on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question. +Planning Mode keeps the running plan visible beside answered-question history and the current question; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview; the AI never ends it on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question. Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 09b06fe9d8..026b86fab6 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2619,6 +2619,14 @@ export function validatePlanningSession(sessionId: string, projectId?: string): return api<{ summary: PlanningSummary; validated: boolean }>(withProjectId(`/planning/${encodeURIComponent(sessionId)}/validate`, projectId), { method: "POST" }); } +/** Rename a planning session after the server verifies the session type. */ +export function updatePlanningSessionTitle(sessionId: string, title: string, projectId?: string): Promise<{ sessionId: string; title: string }> { + return api<{ sessionId: string; title: string }>(withProjectId(`/planning/${encodeURIComponent(sessionId)}/title`, projectId), { + method: "PATCH", + body: JSON.stringify({ title }), + }); +} + /** Submit a response to the current planning question */ export function respondToPlanning( sessionId: string, @@ -2636,8 +2644,8 @@ export function rewindPlanningSession( sessionId: string, projectId?: string, questionId?: string, -): Promise<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }> { - return api<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }>( +): Promise<{ currentQuestion: PlanningQuestion; summary?: PlanningSummary; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }> { + return api<{ currentQuestion: PlanningQuestion; summary?: PlanningSummary; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }>( withProjectId(`/planning/${encodeURIComponent(sessionId)}/back`, projectId), { method: "POST", diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index f8c707184e..8ebdd48123 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -909,6 +909,63 @@ An empty footer must NOT reserve vertical space or paint its divider band. When justify-content: center; } +/* +FNXC:PlanningMode 2026-07-19-12:00: +The desktop interview is three panes: answered history, a single question editor, and an always-mounted +running plan. On narrow screens the established detail navigation stacks panes without removing the plan. +*/ +.planning-running-plan { + display: flex; + flex: 0 1 24rem; + flex-direction: column; + gap: var(--space-md); + min-width: 0; + padding: var(--space-lg); + border-left: 1px solid var(--border); + background: var(--surface); + overflow: auto; +} + +.planning-running-plan h4, +.planning-running-plan h5 { + margin: 0; +} + +/* +FNXC:PlanningMode 2026-07-19-15:55: +Answered-history rows are the sole in-session navigation: make each question a full-width, readable +button so an earlier response can be edited without restoring the retired linear Back control. +*/ +.planning-answered-history-edit { + width: 100%; + justify-content: flex-start; + gap: var(--space-sm); + overflow: hidden; + text-align: left; +} + +.planning-answered-history-edit span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.planning-answered-history-edit.active { + border-color: var(--todo); + background: var(--card-hover); +} + +.planning-running-plan-content { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-sm); +} + +.planning-running-plan-content p { + white-space: pre-wrap; +} + /* Question View */ .planning-question { display: flex; @@ -918,56 +975,6 @@ An empty footer must NOT reserve vertical space or paint its divider band. When min-height: 0; } -.planning-progress { - display: flex; - flex-direction: column; - gap: var(--space-xs); - padding-bottom: 12px; - border-bottom: 1px solid var(--border); - 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)); - gap: var(--space-sm); -} - -.planning-progress-step { - height: 4px; - border-radius: var(--radius-pill); - background: var(--border); - transition: background-color var(--transition-fast); -} - -.planning-progress-step.active { - background: var(--todo); -} - -.planning-progress-text { - font-size: 12px; - color: var(--text-muted); -} - .planning-question-form { display: flex; flex-direction: column; @@ -1851,26 +1858,10 @@ 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); - } - - .planning-progress-text { - font-size: 11px; + .planning-running-plan { + flex-basis: auto; + border-top: 1px solid var(--border); + border-left: 0; } /* Confirm buttons: mobile-friendly sizing */ @@ -2008,3 +1999,10 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer .subtask-item-header--between { justify-content: space-between; } + +.planning-answered-history-response { + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 6d85faa0b1..79e3d3714f 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -33,6 +33,7 @@ import { stopPlanningGeneration, updatePlanningSessionDraft, summarizePlanningDraftTitle, + updatePlanningSessionTitle, updateGlobalSettings, fetchGlobalSettings, type PlanningSession, @@ -51,7 +52,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, Copy } 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, Pencil } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ConversationHistory } from "./ConversationHistory"; import { MailboxMessageContent } from "./MailboxMessageContent"; @@ -62,7 +63,6 @@ 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 = "⚠️"; @@ -312,9 +312,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const [activePlanPrompt, setActivePlanPrompt] = useState(""); const [view, setView] = useState({ type: "initial" }); const [error, setError] = useState(null); - const [responseHistory, setResponseHistory] = useState([]); + const [, setResponseHistory] = useState([]); const [conversationHistory, setConversationHistory] = useState([]); const [editedSummary, setEditedSummary] = useState(null); + // FNXC:PlanningMode 2026-07-19-15:35: FN-8400 keeps the in-progress plan independent of the center-pane view so it remains visible while the next question is generating. + const [runningSummary, setRunningSummary] = useState(null); const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default"); const [branchName, setBranchName] = useState(""); const [baseBranch, setBaseBranch] = useState(""); @@ -334,14 +336,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const [isStartingBreakdown, setIsStartingBreakdown] = useState(false); const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false); /* - FNXC:PlanningMode 2026-07-05-00:00: - FN-7615: Back is deterministic history navigation (a pure server-side rewind that pops the last - history entry), not AI generation. isBackPending drives a lightweight inline pending state on the - Back button itself so the QuestionForm stays mounted throughout — it must never trigger the - `.planning-loading` generation view (spinner + "Generating next question..."), which is reserved - for real model-generation turns. + FNXC:PlanningMode 2026-07-19-12:00: + Interview navigation is selected from answered-question history rather than a linear Back action. + Pending state belongs to the selected history entry and never replaces the running plan pane. */ - const [isBackPending, setIsBackPending] = useState(false); + const [editingQuestionId, setEditingQuestionId] = useState(null); + const [isHistoryEditPending, setIsHistoryEditPending] = useState(false); + const [isRenamingSession, setIsRenamingSession] = useState(false); + const [sessionTitleDraft, setSessionTitleDraft] = useState(""); + const [loadedSessionTitle, setLoadedSessionTitle] = useState(null); const [isRefiningSummary, setIsRefiningSummary] = useState(false); const [generationStartTime, setGenerationStartTime] = useState(null); const [elapsedSeconds, setElapsedSeconds] = useState(0); @@ -722,6 +725,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); + setRunningSummary(null); + setLoadedSessionTitle(null); setBranchMode("project-default"); setBranchName(""); setBaseBranch(""); @@ -854,7 +859,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "question", - session: { sessionId, currentQuestion: normalizedQuestion, summary: null }, + session: { sessionId, currentQuestion: normalizedQuestion, summary: runningSummary }, }); setStreamingOutput(""); }, @@ -884,6 +889,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat summary: normalizedSummary, }); setEditedSummary(normalizedSummary); + setRunningSummary(normalizedSummary); setStreamingOutput(""); }, onError: (message) => { @@ -1091,24 +1097,6 @@ 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) => { if (clarificationSettingsLoading) return; const plan = planOverride ?? initialPlan; @@ -1233,6 +1221,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); + setRunningSummary(null); + setLoadedSessionTitle(null); setIsRetrying(false); setIsRefiningSummary(false); refineSummaryInFlightRef.current = false; @@ -1251,6 +1241,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat return; } + /* + FNXC:PlanningMode 2026-07-19-23:10: + A resumed row can load before the background session-list refresh. Keep its title in the + local session list so the in-session rename control is available instead of disappearing + during that race. + */ + const loadedSessionSummary: AiSessionSummary = { + id: session.id, + type: "planning", + status: session.status, + title: session.title, + projectId: session.projectId ?? null, + updatedAt: session.updatedAt, + archived: session.archived, + }; + setPlanningSessions((previous) => dedupeSessionsById([loadedSessionSummary, ...previous])); + setLoadedSessionTitle(session.title); + currentSessionIdRef.current = sessionId; let inputPayload: Record | null = null; try { @@ -1271,6 +1279,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat Boolean(response && typeof response === "object" && !Array.isArray(response)), ), ); + /* + FNXC:PlanningMode 2026-07-19-22:55: + Reconnected active, loading, and error sessions persist their running summary in `result`. + Hydrate it before selecting a center-pane state so reload/poll recovery cannot blank the plan. + */ + const persistedRunningSummary = session.result + ? normalizePlanningSummary(JSON.parse(session.result)) + : null; + setRunningSummary(persistedRunningSummary); if (session.status === "error") { const errorMessage = session.error || t("planning.sessionFailed2", "Session failed"); @@ -1326,7 +1343,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat resetPlanningAutoRetryBudget(); clearPlanningDescription(projectId); const question = normalizeQuestionOptions(JSON.parse(session.currentQuestion)); - setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } }); + setView({ type: "question", session: { sessionId, currentQuestion: question, summary: persistedRunningSummary } }); // Transfer persisted thinking into conversation history so it's // visible as expandable reasoning in the question view, instead of // setting streamingOutput which is only rendered in the loading @@ -1345,7 +1362,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } else if (session.status === "complete" && session.result) { resetPlanningAutoRetryBudget(); clearPlanningDescription(projectId); - const summary = normalizePlanningSummary(JSON.parse(session.result)); + const summary = persistedRunningSummary ?? normalizePlanningSummary(JSON.parse(session.result)); setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary }); setEditedSummary(summary); } else if (session.status === "generating") { @@ -1845,6 +1862,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } setError(null); + // Capture before clearing state: the edit branch rewrites this exact history row while + // the server preserves the other answers and generates the appended next question. + const submittedEditingQuestionId = editingQuestionId; + setEditingQuestionId(null); // Keep the existing SSE connection alive - do NOT close it! // The connection established in handleStartPlanning will continue @@ -1852,7 +1873,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // This prevents the race condition where events are missed because // the frontend disconnects and reconnects after the API call. - setResponseHistory((prev) => [...prev, responses]); + setResponseHistory((prev) => submittedEditingQuestionId + ? prev.map((response, index) => conversationHistory.filter((entry) => entry.question && entry.response)[index]?.question?.id === submittedEditingQuestionId ? responses : response) + : [...prev, responses]); setConversationHistory((prev) => { // Capture any reasoning that accumulated since the last question // (e.g. thinking streamed while the user was reading the question). @@ -1864,13 +1887,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat updated = [...updated, { thinkingOutput: currentThinking }]; } } - return [ - ...updated, - { - question: activeQuestion, - response: responses, - }, - ]; + const answer = { question: activeQuestion, response: responses }; + if (submittedEditingQuestionId) { + return updated.map((entry) => entry.question?.id === submittedEditingQuestionId ? answer : entry); + } + return [...updated, answer]; }); resetPlanningAutoRetryBudget(); setView({ type: "loading" }); @@ -1886,7 +1907,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setView({ type: "question", session }); } }, - [projectId, resetPlanningAutoRetryBudget, view] + [conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, view] ); const handleStopGeneration = useCallback(async () => { @@ -1925,18 +1946,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat await startPlanningRetry(view.session, { auto: false }); }, [resetPlanningAutoRetryBudget, startPlanningRetry, view]); + /* + FNXC:PlanningMode 2026-07-19-22:50: + Validation belongs to the always-visible running-plan pane, not the center question state. A user + may finalize during loading or recoverable error states; the server cancels an active turn safely. + */ const handleValidatePlan = useCallback(async () => { - if (view.type !== "question") return; + const sessionId = selectedSessionId; + if (!sessionId) return; setError(null); try { - const result = await validatePlanningSession(view.session.sessionId, projectId); + const result = await validatePlanningSession(sessionId, projectId); const summary = normalizePlanningSummary(result.summary); setEditedSummary(summary); - setView({ type: "summary", session: { ...view.session, summary }, summary }); + setRunningSummary(summary); + setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary }); } catch (err) { setError(getErrorMessage(err) || t("planning.failedValidatePlan", "Failed to validate plan")); } - }, [projectId, t, view]); + }, [projectId, selectedSessionId, t]); const handleCreateTask = useCallback(async () => { if (view.type !== "summary") return; @@ -2053,65 +2081,51 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } }, [baseBranch, branchMode, branchName, handleClose, view, onTasksCreated, projectId, workflowId]); - /* - FNXC:PlanningMode 2026-07-05-00:00: - FN-7615: Back must never render `.planning-loading`. rewindSession (src/planning.ts) is a - deterministic history pop + `question` SSE broadcast — it performs no model call — so treating it - like a generation turn (setView({type:"loading"})) was a bug: the user perceives Back as "not - working" because it flashes a spinner/"Generating next question..." screen for an instant, - synchronous-feeling navigation. Stay on the question view throughout; use isBackPending only to - disable the Back/submit controls while the request is in flight. On success, apply the - authoritative rewound history/question (same mapping as before). On failure, surface - planning.failedGoBack and remain on the question view — never loading. - */ - const handleBack = useCallback(async () => { - if (view.type !== "question" || responseHistory.length === 0) { + const handleSelectAnsweredQuestion = useCallback(async (entry: ConversationHistoryEntry) => { + const questionId = entry.question?.id; + if (view.type !== "question" || !questionId) return; + setError(null); + setIsHistoryEditPending(true); + try { + const rewound = await rewindPlanningSession(view.session.sessionId, projectId, questionId); + setEditingQuestionId(questionId); + setConversationHistory(rewound.history.map((item) => ({ + question: item.question, + response: item.response && typeof item.response === "object" && !Array.isArray(item.response) + ? item.response as Record + : { [item.question.id]: item.response }, + thinkingOutput: item.thinkingOutput, + }))); + const nextSummary = rewound.summary ? normalizePlanningSummary(rewound.summary) : runningSummary; + setRunningSummary(nextSummary); + setView({ type: "question", session: { ...view.session, currentQuestion: rewound.currentQuestion, summary: nextSummary } }); + } catch (err) { + setError(getErrorMessage(err) || t("planning.failedGoBack", "Failed to edit the selected answer")); + } finally { + setIsHistoryEditPending(false); + } + }, [projectId, runningSummary, t, view]); + + const activeSessionTitle = planningSessions.find((session) => session.id === selectedSessionId)?.title ?? loadedSessionTitle; + const handleRenameSession = useCallback(async () => { + const sessionId = selectedSessionId; + const nextTitle = sessionTitleDraft.trim(); + if (!sessionId || !nextTitle || nextTitle === activeSessionTitle) { + setIsRenamingSession(false); return; } - - const sessionId = view.session.sessionId; - setError(null); - setIsBackPending(true); - + const previousTitle = activeSessionTitle; + setPlanningSessions((sessions) => sessions.map((session) => session.id === sessionId ? { ...session, title: nextTitle } : session)); + setLoadedSessionTitle(nextTitle); + setIsRenamingSession(false); try { - const rewound = await rewindPlanningSession(sessionId, projectId); - setResponseHistory(rewound.history.map((entry) => { - if (entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)) { - return entry.response as QuestionResponse; - } - return { [entry.question.id]: entry.response }; - })); - setConversationHistory(rewound.history.map((entry) => ({ - question: entry.question, - response: - entry.response && typeof entry.response === "object" && !Array.isArray(entry.response) - ? (entry.response as Record) - : { [entry.question.id]: entry.response }, - thinkingOutput: entry.thinkingOutput, - }))); - setStreamingOutput(""); - setView({ - type: "question", - session: { - ...view.session, - currentQuestion: rewound.currentQuestion, - summary: null, - }, - }); + await updatePlanningSessionTitle(sessionId, nextTitle, projectId); } catch (err) { - setError(getErrorMessage(err) || t("planning.failedGoBack", "Failed to go back to the previous question")); - setView({ type: "question", session: view.session }); - } finally { - setIsBackPending(false); + setPlanningSessions((sessions) => sessions.map((session) => session.id === sessionId ? { ...session, title: previousTitle ?? session.title } : session)); + setLoadedSessionTitle(previousTitle ?? null); + setError(getErrorMessage(err) || t("planning.renameSession", "Rename session")); } - }, [projectId, responseHistory.length, t, view]); - - const getProgress = () => { - if (view.type === "question") { - return Math.min(responseHistory.length + 1, 3); - } - return 3; - }; + }, [activeSessionTitle, projectId, selectedSessionId, sessionTitleDraft, t]); /* FNXC:PlanningMode 2026-06-21-00:00: @@ -2158,7 +2172,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat Header icon mirrors MissionManager's : same size (20) and same var(--todo) tint + flex-shrink:0, applied via the scoped .planning-modal--embedded .modal-header--embedded .detail-title-row > svg rule (it overrides the shared icon-triage brown so the two headers read as siblings). */} -

{t("planning.title", "Planning Mode")}

+ {view.type === "question" && activeSessionTitle && isRenamingSession ? ( + setSessionTitleDraft(event.target.value)} + onBlur={() => void handleRenameSession()} + onKeyDown={(event) => { if (event.key === "Enter") void handleRenameSession(); }} + autoFocus + /> + ) : ( + <>

{view.type === "question" && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}

+ {view.type === "question" && activeSessionTitle && } + )} {!isEmbedded && (
@@ -2174,6 +2201,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list" }`} > + {view.type === "question" ? ( + void handleSelectAnsweredQuestion(entry)} + /> + ) : ( void handleDeleteSession(id)} onCancelDelete={() => setPendingDeleteId(null)} /> + )} {/* FNXC:Planning 2026-06-23-02:00: @@ -2463,18 +2499,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat {isRetrying ? : } {isRetrying ? t("planning.retrying", "Retrying...") : t("common.retry", "Retry")} - {activePlanPrompt.trim() && ( - - )}
@@ -2486,16 +2510,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
entry.question?.id === editingQuestionId)?.response + : undefined} onSubmit={handleSubmitResponse} - onBack={responseHistory.length > 0 ? handleBack : undefined} - isBackPending={isBackPending} - onCopyPlanPrompt={activePlanPrompt.trim() ? handleCopyPlanPrompt : undefined} /> -
)} @@ -2543,6 +2562,19 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat )} + {/* + FNXC:PlanningMode 2026-07-19-15:45: + Keep the running-plan pane mounted for an active session while a question is loading or an + error is recoverable. A session selection is the stable identity across those view states; + tying this pane to only the question view recreated the old dead-end interface. + */} + {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && ( + void handleValidatePlan()} + /> + )} @@ -2550,20 +2582,84 @@ 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. +FNXC:PlanningMode 2026-07-19-12:00: +The active-session left pane is answer history, not a second copy of the interview. Selecting a row +uses the server's question-id edit-and-branch contract, while the center remains the sole answer editor. */ -interface QuestionFormProps { - question: PlanningQuestion; - progress: number; - historyEntries: ConversationHistoryEntry[]; - onSubmit: (responses: QuestionResponse) => void; - onBack?: () => void; - isBackPending?: boolean; - onCopyPlanPrompt?: () => void | Promise; +function AnsweredQuestionHistory({ entries, selectedQuestionId, isPending, onSelect }: { + entries: ConversationHistoryEntry[]; + selectedQuestionId?: string | null; + isPending: boolean; + onSelect: (entry: ConversationHistoryEntry) => void; +}) { + const { t } = useTranslation("app"); + const answered = entries.filter((entry) => entry.question && entry.response); + return ( + + ); } -function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmit, onBack, isBackPending = false, onCopyPlanPrompt }: QuestionFormProps) { +/* +FNXC:PlanningMode 2026-07-19-12:00: +Keep the running plan mounted beside every interview state, including loading and errors. Validation is +an intentional user action; no generation state offers a competing completion path. +*/ +function formatHistoryResponse(entry: ConversationHistoryEntry): string { + const response = entry.response ?? {}; + const options = entry.question?.options ?? []; + const optionLabel = (value: string | number | boolean): string => { + const matchingOption = typeof value === "string" ? options.find((option) => option.id === value) : undefined; + return matchingOption?.label ?? String(value); + }; + return Object.entries(response) + .filter(([key]) => key !== "_comment") + .flatMap(([, value]) => Array.isArray(value) ? value : [value]) + .filter((value): value is string | number | boolean => typeof value === "string" || typeof value === "number" || typeof value === "boolean") + .map(optionLabel) + .join(", "); +} + +function RunningPlanPane({ summary, fallbackDescription, onValidate }: { summary?: PlanningSummary | null; fallbackDescription: string; onValidate?: () => void }) { + const { t } = useTranslation("app"); + const plan = normalizePlanningSummary(summary ?? { title: "", description: fallbackDescription, suggestedSize: "M", priority: DEFAULT_TASK_PRIORITY, suggestedDependencies: [], keyDeliverables: [] }); + return ( + + ); +} + +interface QuestionFormProps { + question: PlanningQuestion; + initialResponse?: QuestionResponse; + onSubmit: (responses: QuestionResponse) => void; +} + +function QuestionForm({ question: rawQuestion, initialResponse, onSubmit }: QuestionFormProps) { const { t } = useTranslation("app"); const question = normalizeQuestionOptions(rawQuestion); const questionOptions = question.options ?? []; @@ -2638,14 +2734,17 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi onSubmit(nextResponse); }, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]); - // Reset state when question changes + // Restore a selected history answer so editing is a direct, non-destructive operation. useEffect(() => { - setResponse({}); - setTextValue(""); - setCommentValue(""); - setOtherValue(""); - setIsOtherSelected(false); - }, [question.id]); + const prior = initialResponse ?? {}; + const other = typeof prior[PLANNING_OTHER_RESPONSE_KEY] === "string" ? prior[PLANNING_OTHER_RESPONSE_KEY] : ""; + const text = prior[question.id]; + setResponse(prior); + setTextValue(typeof text === "string" ? text : ""); + setCommentValue(typeof prior._comment === "string" ? prior._comment : ""); + setOtherValue(other); + setIsOtherSelected(Boolean(other)); + }, [initialResponse, question.id]); const isValid = () => { switch (question.type) { @@ -2654,7 +2753,7 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi case "single_select": /* FNXC:PlanningInterview 2026-06-26-00:00: - The Other radio is a first-class valid answer only when it has non-whitespace text; the Continue button must not force an unwanted provided option. + The Other radio is a first-class valid answer only when it has non-whitespace text; the Next question button must not force an unwanted provided option. */ return response[question.id] !== undefined || (isOtherSelected && otherValue.trim().length > 0); case "multi_select": @@ -2673,40 +2772,7 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi return (
- {historyEntries.length > 0 && ( - <> - -
- - )} -
-
-
-
- {[1, 2, 3].map((step) => ( -
- ))} -
- {onCopyPlanPrompt && ( - - )} -
- {t("planning.questionProgress", "Question {{progress}} of ~3", { progress })} -
-
{/* FNXC:PlanningInterview 2026-07-16-00:00: @@ -2928,18 +2994,12 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
- {onBack && ( - - )}
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 0f973af237..8931c1d6ae 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -66,6 +66,7 @@ import { const mockAddToast = vi.fn(); const mockCopyTextToClipboard = vi.fn(); +const mockUpdatePlanningSessionTitle = vi.fn(); /* FNXC:PlanningModeStreamHarness 2026-07-17-16:20: @@ -98,6 +99,7 @@ vi.mock("../../api", () => ({ cancelPlanning: (...args: any[]) => mockCancelPlanning(...args), stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args), + updatePlanningSessionTitle: (...args: any[]) => mockUpdatePlanningSessionTitle(...args), createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args), validatePlanningSession: (...args: any[]) => mockValidatePlanningSession(...args), startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args), @@ -183,6 +185,8 @@ describe("PlanningModeModal", () => { mockAddToast.mockReset(); mockCopyTextToClipboard.mockReset(); mockCopyTextToClipboard.mockResolvedValue(true); + mockUpdatePlanningSessionTitle.mockReset(); + mockUpdatePlanningSessionTitle.mockResolvedValue({ sessionId: "session-123", title: "Renamed session" }); MockEventSource.reset(); vi.stubGlobal("EventSource", MockEventSource as any); window.sessionStorage.clear(); @@ -379,14 +383,14 @@ describe("PlanningModeModal", () => { /* FNXC:DashboardTests 2026-07-18-15:20: - Full Suite shard 3 (29648952207) observed Small+Continue not reaching respondToPlanning + Full Suite shard 3 (29648952207) observed Small+Next question not reaching respondToPlanning under load (0 calls). Click the option radio by role and wait for checked + respond with the same settle bound as "allows normal question interaction". */ const smallOption = screen.getByRole("radio", { name: /Small/i }); fireEvent.click(smallOption); await waitFor(() => expect(smallOption).toBeChecked()); - fireEvent.click(screen.getByRole("button", { name: "Continue" })); + fireEvent.click(screen.getByRole("button", { name: "Next question" })); await waitFor( () => { @@ -427,7 +431,7 @@ describe("PlanningModeModal", () => { }); fireEvent.click(screen.getByText("Small")); - fireEvent.click(screen.getByText("Continue")); + fireEvent.click(screen.getByText("Next question")); await waitFor( () => { @@ -466,7 +470,7 @@ describe("PlanningModeModal", () => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); - const continueButton = screen.getByRole("button", { name: "Continue" }); + const continueButton = screen.getByRole("button", { name: "Next question" }); fireEvent.click(screen.getByTestId("planning-option-other")); const otherInput = await screen.findByTestId("planning-other-input"); expect(continueButton).toBeDisabled(); @@ -505,7 +509,7 @@ describe("PlanningModeModal", () => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); - const continueButton = screen.getByRole("button", { name: "Continue" }); + const continueButton = screen.getByRole("button", { name: "Next question" }); fireEvent.click(screen.getByTestId("planning-option-other")); const otherInput = await screen.findByTestId("planning-other-input"); fireEvent.change(otherInput, { target: { value: " " } }); @@ -565,7 +569,7 @@ describe("PlanningModeModal", () => { expect(screen.getByText("Which priorities matter?")).toBeDefined(); }); - const continueButton = screen.getByRole("button", { name: "Continue" }); + const continueButton = screen.getByRole("button", { name: "Next question" }); /* FNXC:PlanningModeOptions 2026-07-18-14:00: Full Suite shard 3 (29646721723) timed out finding planning-other-input after a bare @@ -630,7 +634,7 @@ describe("PlanningModeModal", () => { expect(screen.getByText("Which priorities matter?")).toBeDefined(); }); - const continueButton = screen.getByRole("button", { name: "Continue" }); + const continueButton = screen.getByRole("button", { name: "Next question" }); /* FNXC:PlanningModeOptions 2026-07-18-10:35: Full-suite shard load observed getByText("Speed") not committing the multi-select @@ -705,9 +709,9 @@ describe("PlanningModeModal", () => { Full Suite shard 3 (29643371961) failed when fireEvent.click(Other) did not flush isOtherSelected before the synchronous getByTestId(planning-other-input) under CI load. Await findByTestId after the confirm Other button click (same settle discipline - as multi-select Other) before asserting Continue enablement and the _other payload. + as multi-select Other) before asserting Next question enablement and the _other payload. */ - const continueButton = screen.getByRole("button", { name: "Continue" }); + const continueButton = screen.getByRole("button", { name: "Next question" }); fireEvent.click(screen.getByTestId("planning-option-other")); const otherInput = await screen.findByTestId("planning-other-input"); expect(continueButton).toBeDisabled(); @@ -765,7 +769,7 @@ describe("PlanningModeModal", () => { expect(screen.getByText("Proceed with this scope?")).toBeDefined(); }); - const continueButton = screen.getByRole("button", { name: "Continue" }); + const continueButton = screen.getByRole("button", { name: "Next question" }); fireEvent.click(screen.getByTestId("planning-option-other")); const otherInput = await screen.findByTestId("planning-other-input"); fireEvent.change(otherInput, { @@ -3514,7 +3518,7 @@ describe("PlanningModeModal", () => { const mediumOption = await screen.findByText("Medium"); fireEvent.click(mediumOption); - const continueBtn = await screen.findByRole("button", { name: "Continue" }); + const continueBtn = await screen.findByRole("button", { name: "Next question" }); fireEvent.click(continueBtn); await waitFor(() => { @@ -3527,15 +3531,100 @@ describe("PlanningModeModal", () => { }); }); + describe("session rename", () => { + function renderActiveSessionForRename() { + mockFetchAiSession.mockResolvedValueOnce({ + id: "session-rename", + type: "planning", + status: "awaiting_input", + title: "Original session", + inputPayload: JSON.stringify({ initialPlan: "Rename this session" }), + conversationHistory: "[]", + currentQuestion: JSON.stringify(mockQuestion), + result: JSON.stringify(mockSummary), + thinkingOutput: "", + projectId: null, + }); + return render(); + } + + it("optimistically renames the active session through the dedicated API", async () => { + renderActiveSessionForRename(); + await screen.findByText("What is the scope?"); + fireEvent.click(screen.getByRole("button", { name: "Rename session" })); + fireEvent.change(screen.getByRole("textbox", { name: "Rename session" }), { target: { value: "Updated session" } }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Rename session" }), { key: "Enter" }); + + await waitFor(() => expect(mockUpdatePlanningSessionTitle).toHaveBeenCalledWith("session-rename", "Updated session", undefined)); + expect(screen.getByRole("heading", { name: "Updated session" })).toBeDefined(); + }); + + it("rolls a rejected rename back to the persisted title", async () => { + mockUpdatePlanningSessionTitle.mockRejectedValueOnce(new Error("Rename rejected")); + renderActiveSessionForRename(); + await screen.findByText("What is the scope?"); + fireEvent.click(screen.getByRole("button", { name: "Rename session" })); + fireEvent.change(screen.getByRole("textbox", { name: "Rename session" }), { target: { value: "Rejected rename" } }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Rename session" }), { key: "Enter" }); + + await screen.findByText("Rename rejected"); + expect(screen.getByRole("heading", { name: "Original session" })).toBeDefined(); + }); + }); + + describe.each(["desktop", "mobile"] as const)("single interview action on %s", (viewport) => { + it("keeps only Next question and the localized Other input affordance", async () => { + mockViewport(viewport); + render(); + + await screen.findByText("What is the scope?"); + expect(screen.getAllByRole("button", { name: "Next question" })).toHaveLength(1); + expect(screen.getByRole("radio", { name: "Other (write your own)" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + expect(screen.queryByRole("button", { name: /Copy prompt/i })).toBeNull(); + expect(screen.queryByText(/Question .* of ~3/i)).toBeNull(); + }); + }); + /* - FNXC:PlanningMode 2026-07-05-00:00: - FN-7615 regression coverage: Back is deterministic history navigation (a pure server-side - rewind), not AI generation, so it must never render `.planning-loading` (the "Generating next - question..."/"AI is thinking..." spinner + Stop screen reserved for real model turns). Cover the - success path, the failure path (error surfaced, still on a question form), and the - no-history-yet state where the Back button is absent. + FNXC:PlanningMode 2026-07-19-23:00: + The running plan and user-controlled validation must survive every recoverable session state, + including reconnect/loading/error paths that do not mount the center question editor. */ - describe("Back navigation (FN-7615)", () => { + describe.each([ + ["awaiting_input", { currentQuestion: JSON.stringify(mockQuestion) }], + ["generating", {}], + ["error", { error: "Generation failed" }], + ] as const)("running plan for %s sessions", (status, fields) => { + it("keeps the plan and Validate plan control visible", async () => { + mockFetchAiSession.mockResolvedValueOnce({ + id: `session-${status}`, + type: "planning", + status, + title: "Persisted planning session", + inputPayload: JSON.stringify({ initialPlan: "Persisted plan prompt" }), + conversationHistory: "[]", + result: JSON.stringify(mockSummary), + thinkingOutput: "", + projectId: null, + ...fields, + }); + + render(); + + expect(await screen.findByRole("complementary", { name: "Running plan" })).toHaveTextContent(mockSummary.title); + fireEvent.click(screen.getByRole("button", { name: "Validate plan" })); + await waitFor(() => expect(mockValidatePlanningSession).toHaveBeenCalledWith(`session-${status}`, undefined)); + }); + }); + + /* + FNXC:PlanningMode 2026-07-19-15:50: + FN-8400 replaces linear interview Back with an answered-question edit action. Selecting history + must use the question-id rewind contract without presenting generation UI, then restore the answer + in the sole center-pane editor so the next question branches from the revised response. + */ + describe("answered-question editing", () => { const secondQuestion: PlanningQuestion = { id: "q-requirements", type: "text", @@ -3543,299 +3632,97 @@ describe("PlanningModeModal", () => { description: "Describe the requirements", }; - const thirdQuestion: PlanningQuestion = { - id: "q-details", - type: "text", - question: "Any additional details?", - description: "Optional extra context", - }; - - async function advanceToThirdQuestion() { + async function advanceToSecondQuestion() { let streamHandlers: any; mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; - queuePlanningStreamEvent(() => { - handlers.onQuestion?.(mockQuestion); - }); - - return { - close: vi.fn(), - isConnected: vi.fn().mockReturnValue(true), - }; + queuePlanningStreamEvent(() => handlers.onQuestion?.(mockQuestion)); + return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) }; }); - - let respondCallCount = 0; - mockRespondToPlanning.mockImplementation(async () => { - respondCallCount += 1; - const nextQuestion = respondCallCount === 1 ? secondQuestion : thirdQuestion; - queuePlanningStreamEvent(() => { - streamHandlers?.onQuestion?.(nextQuestion); - }); + mockRespondToPlanning.mockImplementationOnce(async () => { + queuePlanningStreamEvent(() => streamHandlers?.onQuestion?.(secondQuestion)); return { sessionId: "session-123", currentQuestion: null, summary: null }; }); - const renderResult = render( - , - ); - - fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { - target: { value: "Build auth system" }, - }); + const result = render(); + fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); - - await waitFor(() => { - expect(screen.getByText("What is the scope?")).toBeDefined(); - }); - - const mediumOption = await screen.findByText("Medium"); - fireEvent.click(mediumOption); - fireEvent.click(await screen.findByRole("button", { name: "Continue" })); - - await waitFor(() => { - expect(screen.getByText("What are the key requirements?")).toBeDefined(); - }, { timeout: 5000 }); - - const requirementsTextarea = screen.getByPlaceholderText("Type your answer here..."); - fireEvent.change(requirementsTextarea, { target: { value: "Auth requirements" } }); - fireEvent.click(await screen.findByRole("button", { name: "Continue" })); - - await waitFor(() => { - expect(screen.getByText("Any additional details?")).toBeDefined(); - }, { timeout: 5000 }); - - return renderResult; + await screen.findByText("What is the scope?"); + fireEvent.click(await screen.findByText("Medium")); + fireEvent.click(await screen.findByRole("button", { name: "Next question" })); + await screen.findByText("What are the key requirements?"); + return result; } - it("never renders the generation screen while going back, and restores the previous question with prior Q&A visible", async () => { - let resolveRewind!: (value: { - currentQuestion: PlanningQuestion; - history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }>; - }) => void; - mockRewindPlanningSession.mockImplementation( - () => new Promise((resolve) => { - resolveRewind = resolve; - }), - ); - - const { container } = await advanceToThirdQuestion(); - - const backButton = screen.getByRole("button", { name: /Back/i }); - - await act(async () => { - fireEvent.click(backButton); + it("edits an answered question without replacing the interview with generation UI", async () => { + mockRewindPlanningSession.mockResolvedValueOnce({ + currentQuestion: mockQuestion, + history: [{ question: mockQuestion, response: { [mockQuestion.id]: "medium" } }], }); + const { container } = await advanceToSecondQuestion(); + + fireEvent.click(screen.getByRole("button", { name: "Edit answer for What is the scope?" })); - // Symptom assertion (FN-7615): immediately after the click, while the deterministic - // rewind is still in flight, the generation view must never be present. expect(container.querySelector(".planning-loading")).toBeNull(); - expect(screen.queryByText("Generating next question...")).toBeNull(); - expect(screen.queryByText("AI is thinking...")).toBeNull(); - - await act(async () => { - resolveRewind({ - currentQuestion: secondQuestion, - history: [{ question: mockQuestion, response: { [mockQuestion.id]: "medium" } }], - }); - }); - - await waitFor(() => { - expect(screen.getByText("What are the key requirements?")).toBeDefined(); - }); - - // Symptom assertion (FN-7615): after the async rewind settles, the generation view must - // still never have appeared, and the previous question form is shown with the prior Q&A - // (Q1's restored answer) visible above it. - expect(container.querySelector(".planning-loading")).toBeNull(); - expect(screen.getByTestId("conversation-history")).toBeDefined(); - expect(screen.getByText("What is the scope?")).toBeDefined(); - expect(screen.getByText("Medium")).toBeDefined(); - expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined); + await screen.findByDisplayValue("medium"); + expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined, mockQuestion.id); + expect(screen.getByRole("button", { name: "Next question" })).toBeDefined(); }); - it("stays on the question form and surfaces an error when the rewind request fails", async () => { + it("keeps the current question visible when question-id rewind fails", async () => { mockRewindPlanningSession.mockRejectedValueOnce(new Error("rewind failed")); + const { container } = await advanceToSecondQuestion(); - const { container } = await advanceToThirdQuestion(); - - const backButton = screen.getByRole("button", { name: /Back/i }); - - await act(async () => { - fireEvent.click(backButton); - }); + fireEvent.click(screen.getByRole("button", { name: "Edit answer for What is the scope?" })); expect(container.querySelector(".planning-loading")).toBeNull(); - - await waitFor(() => { - expect(screen.getByText("rewind failed")).toBeDefined(); - }); - - // Still on a question form (not loading, not generation) after the failure. - expect(container.querySelector(".planning-loading")).toBeNull(); - expect(screen.getByText("Any additional details?")).toBeDefined(); + await screen.findByText("rewind failed"); + expect(screen.getByText("What are the key requirements?")).toBeDefined(); }); - it("does not render a Back button on the first question, before any history exists", async () => { - render( - , - ); - - fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { - target: { value: "Build auth system" }, - }); - fireEvent.click(screen.getByText("Start Planning")); - - await waitFor(() => { - expect(screen.getByText("What is the scope?")).toBeDefined(); - }); - - expect(screen.queryByRole("button", { name: /Back/i })).toBeNull(); - }); - }); - - 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); + it("submits the selected answer through the edit-and-branch response path", async () => { + const branchedQuestion: PlanningQuestion = { + id: "q-branched", + type: "text", + question: "What should the new branch refine?", + }; + let streamHandlers: any; + mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers = handlers; + queuePlanningStreamEvent(() => handlers.onQuestion?.(mockQuestion)); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) }; }); - - render( - , - ); - - fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { - target: { value: originalPrompt }, + mockRespondToPlanning.mockImplementationOnce(async () => { + queuePlanningStreamEvent(() => streamHandlers?.onQuestion?.(secondQuestion)); + return { sessionId: "session-123", currentQuestion: null, summary: null }; + }).mockImplementationOnce(async () => { + queuePlanningStreamEvent(() => streamHandlers?.onQuestion?.(branchedQuestion)); + return { sessionId: "session-123", currentQuestion: null, summary: null }; }); - 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", + mockRewindPlanningSession.mockResolvedValueOnce({ + currentQuestion: mockQuestion, + history: [{ question: mockQuestion, response: { [mockQuestion.id]: "medium" } }], }); - render( - , - ); + render(); + fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" } }); + fireEvent.click(screen.getByText("Start Planning")); + await screen.findByText("What is the scope?"); + fireEvent.click(await screen.findByText("Medium")); + fireEvent.click(screen.getByRole("button", { name: "Next question" })); + await screen.findByText("What are the key requirements?"); + fireEvent.click(screen.getByRole("button", { name: "Edit answer for What is the scope?" })); + await screen.findByDisplayValue("medium"); + fireEvent.click(screen.getByRole("button", { name: "Next question" })); - 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(); + await screen.findByText("What should the new branch refine?"); + expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined, mockQuestion.id); + expect(mockRespondToPlanning).toHaveBeenLastCalledWith("session-123", { [mockQuestion.id]: "medium" }, undefined); + expect(screen.getByRole("button", { name: "Edit answer for What is the scope?" })).toBeDefined(); }); }); + describe("Session history", () => { it("renders only one row when fetch and SSE deliver the same session id", async () => { mockFetchAiSessions.mockResolvedValueOnce([ @@ -4006,7 +3893,7 @@ describe("PlanningModeModal", () => { id: "background-planning-session", type: "planning" as const, status: "awaiting_input" as const, - title: "Continue background planning", + title: "Next question background planning", projectId: null, updatedAt: "2026-07-15T00:00:00.000Z", }]; @@ -4022,7 +3909,7 @@ describe("PlanningModeModal", () => { />, ); - expect(screen.getByRole("button", { name: /Continue background planning/i })).toBeDefined(); + expect(screen.getByRole("button", { name: /Next question background planning/i })).toBeDefined(); expect(screen.queryByTestId("planning-sidebar-skeleton")).toBeNull(); await waitFor(() => expect(mockFetchAiSessions).toHaveBeenCalledTimes(1)); }); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx index 3d3abeeeda..df9b4ecc4a 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx @@ -86,6 +86,7 @@ vi.mock("../../api", () => ({ cancelPlanning: (...args: any[]) => mockCancelPlanning(...args), stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args), + updatePlanningSessionTitle: vi.fn().mockResolvedValue({ sessionId: "session-123", title: "Renamed session" }), createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args), validatePlanningSession: (...args: any[]) => mockValidatePlanningSession(...args), startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args), @@ -101,6 +102,7 @@ vi.mock("../../api", () => ({ pauseTask: (...args: any[]) => mockPauseTask(...args), unpauseTask: (...args: any[]) => mockUnpauseTask(...args), fetchTaskDetail: (...args: any[]) => mockFetchTaskDetail(...args), + fetchTaskVerificationRequest: vi.fn().mockResolvedValue(null), requestSpecRevision: (...args: any[]) => mockRequestSpecRevision(...args), approvePlan: (...args: any[]) => mockApprovePlan(...args), rejectPlan: (...args: any[]) => mockRejectPlan(...args), @@ -583,7 +585,7 @@ describe("PlanningModeModal", () => { // Answer the first question fireEvent.click(screen.getByText("Medium")); - fireEvent.click(screen.getByText("Continue")); + fireEvent.click(screen.getByText("Next question")); // Verify loading state appears with correct message await waitFor(() => { diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 7b99d00a9a..77595078be 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -342,6 +342,13 @@ class MockAiSessionStore extends EventEmitter { this.rows.set(id, { ...row, thinkingOutput, updatedAt: new Date().toISOString() }); } + async updateTitle(id: string, title: string): Promise { + const row = this.rows.get(id); + if (!row) return false; + this.rows.set(id, { ...row, title, updatedAt: new Date().toISOString() }); + return true; + } + async delete(id: string): Promise { this.rows.delete(id); this.emit("ai_session:deleted", id); @@ -551,6 +558,49 @@ describe("Planning Mode Routes", () => { __setCreateFnAgent(undefined as any); }); + describe("PATCH /planning/:sessionId/title", () => { + function buildTitleRouteApp(sessionStore: MockAiSessionStore) { + setAiSessionStore(sessionStore as unknown as Parameters[0]); + return buildApp(); + } + + it("persists a verbatim title for an active planning session", async () => { + const sessionStore = new MockAiSessionStore(); + await sessionStore.upsert(buildPlanningRow({ id: "planning-active-title", status: "awaiting_input", title: "AI draft" })); + + const res = await REQUEST(buildTitleRouteApp(sessionStore), "PATCH", "/api/planning/planning-active-title/title", JSON.stringify({ title: " Operator-defined plan " }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ sessionId: "planning-active-title", title: "Operator-defined plan" }); + expect((await sessionStore.get("planning-active-title"))?.title).toBe("Operator-defined plan"); + }); + + it.each(["", " ", "x".repeat(61)])("rejects an invalid user session title", async (title) => { + const sessionStore = new MockAiSessionStore(); + await sessionStore.upsert(buildPlanningRow({ id: "planning-invalid-title", status: "awaiting_input", title: "Unchanged" })); + + const res = await REQUEST(buildTitleRouteApp(sessionStore), "PATCH", "/api/planning/planning-invalid-title/title", JSON.stringify({ title }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(400); + expect((await sessionStore.get("planning-invalid-title"))?.title).toBe("Unchanged"); + }); + + it("returns 404 for an unknown session", async () => { + const res = await REQUEST(buildTitleRouteApp(new MockAiSessionStore()), "PATCH", "/api/planning/missing/title", JSON.stringify({ title: "No session" }), { "Content-Type": "application/json" }); + expect(res.status).toBe(404); + }); + + it("returns 404 without renaming a non-planning AI session", async () => { + const sessionStore = new MockAiSessionStore(); + await sessionStore.upsert({ ...buildPlanningRow({ id: "chat-title-isolation", status: "awaiting_input", title: "Chat title" }), type: "chat" }); + + const res = await REQUEST(buildTitleRouteApp(sessionStore), "PATCH", "/api/planning/chat-title-isolation/title", JSON.stringify({ title: "Must not rename" }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(404); + expect((await sessionStore.get("chat-title-isolation"))?.title).toBe("Chat title"); + }); + }); + describe("POST /planning/start", () => { it("creates a new planning session", async () => { const res = await REQUEST( diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index e0c13c8183..f35a16932e 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -1318,6 +1318,25 @@ export async function createDraftSession( * Returns the resolved title (existing or freshly generated) or null if the * session was not eligible for summarization. */ +/* +FNXC:PlanningMode 2026-07-19-12:00: +User session names are an explicit Planning Mode control, unlike AI draft summarization. Keep the +planning-type predicate beside the persistence seam so a by-id dashboard route can never rename chat sessions. +*/ +export async function updatePlanningSessionTitle(sessionId: string, title: string): Promise { + if (!_aiSessionStore) return false; + + const row = await _aiSessionStore.get(sessionId); + if (!row || row.type !== "planning") return false; + + const changed = await _aiSessionStore.updateTitle(sessionId, title); + if (changed) { + const session = sessions.get(sessionId); + if (session) session.title = title; + } + return changed; +} + export async function summarizeDraftTitle( sessionId: string, rootDir: string, @@ -1357,7 +1376,7 @@ export async function summarizeDraftTitle( // Re-check status (not title) so a concurrent Start Planning or a later // edit-then-blur cycle doesn't overwrite a real generating/complete title. const latest = await _aiSessionStore.get(sessionId); - if (!latest || latest.status !== "draft") { + if (!latest || latest.type !== "planning" || latest.status !== "draft") { return latest?.title ?? null; } diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 7e667b9126..a451969a83 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -789,6 +789,32 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } }); + /* + FNXC:PlanningMode 2026-07-19-12:00: + Rename is intentionally a lock-free, verbatim user action. The planning helper first checks the + persisted session type so this planning-scoped endpoint cannot mutate another AI-session surface. + */ + router.patch("/planning/:sessionId/title", async (req, res) => { + try { + const { sessionId } = req.params; + const title = req.body?.title; + if (!sessionId || typeof sessionId !== "string") throw badRequest("sessionId is required"); + if (typeof title !== "string") throw badRequest("title is required and must be a string"); + const trimmedTitle = title.trim(); + if (!trimmedTitle) throw badRequest("title must not be empty"); + if (trimmedTitle.length > 60) throw badRequest("title must be 60 characters or less"); + + const { updatePlanningSessionTitle } = await import("../planning.js"); + if (!(await updatePlanningSessionTitle(sessionId, trimmedTitle))) { + throw notFound(`Planning session ${sessionId} not found`); + } + res.json({ sessionId, title: trimmedTitle }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to rename planning session"); + } + }); + /** * POST /api/planning/:sessionId/summarize-draft-title * Generate (or regenerate) the sidebar title for a draft session from its diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index b0e8d103a5..a03e69d09d 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -4684,7 +4684,12 @@ "untitledSession": "Untitled session", "usingDefault": "Using default", "whatToBuild": "What do you want to build?", - "whatToBuildPlaceholder": "e.g., Build a user authentication system with login, signup, and password reset..." + "whatToBuildPlaceholder": "e.g., Build a user authentication system with login, signup, and password reset...", + "nextQuestion": "Next question", + "answeredQuestions": "Answered questions", + "runningPlan": "Running plan", + "renameSession": "Rename session", + "editAnswer": "Edit answer for {{question}}" }, "plugins": { "addItem": "Add Item", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index f276979ddf..8272a96a03 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -4674,7 +4674,12 @@ "usingDefault": "Usar predeterminado", "whatToBuild": "¿Qué quieres construir?", "whatToBuildPlaceholder": "p. ej., Crear un sistema de autenticación con inicio de sesión, registro y restablecimiento de contraseña...", - "agentClarification": "" + "agentClarification": "", + "nextQuestion": "Siguiente pregunta", + "answeredQuestions": "Preguntas respondidas", + "runningPlan": "Plan en curso", + "renameSession": "Renombrar sesión", + "editAnswer": "Editar respuesta para {{question}}" }, "plugins": { "addItem": "Agregar elemento", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 714d9f92fb..e0aa01f95b 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -4674,7 +4674,12 @@ "usingDefault": "Modèle par défaut", "whatToBuild": "Que voulez-vous construire ?", "whatToBuildPlaceholder": "ex : Construire un système d'authentification avec connexion, inscription et réinitialisation du mot de passe…", - "agentClarification": "" + "agentClarification": "", + "nextQuestion": "Question suivante", + "answeredQuestions": "Questions répondues", + "runningPlan": "Plan en cours", + "renameSession": "Renommer la session", + "editAnswer": "Modifier la réponse pour {{question}}" }, "plugins": { "addItem": "Ajouter un élément", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 8002c3bd2f..29d99bdc32 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -4674,7 +4674,12 @@ "usingDefault": "기본값 사용", "whatToBuild": "무엇을 만들고 싶으신가요?", "whatToBuildPlaceholder": "예: 로그인, 회원가입, 비밀번호 재설정이 포함된 사용자 인증 시스템 구축...", - "agentClarification": "" + "agentClarification": "", + "nextQuestion": "다음 질문", + "answeredQuestions": "답변한 질문", + "runningPlan": "진행 중인 계획", + "renameSession": "세션 이름 바꾸기", + "editAnswer": "{{question}} 답변 수정" }, "plugins": { "addItem": "항목 추가", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index e1cacd1c10..0272460f49 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -4674,7 +4674,12 @@ "usingDefault": "使用默认", "whatToBuild": "您想构建什么?", "whatToBuildPlaceholder": "例如,构建一个包含登录、注册和密码重置的用户认证系统...", - "agentClarification": "" + "agentClarification": "", + "nextQuestion": "下一个问题", + "answeredQuestions": "已回答的问题", + "runningPlan": "进行中的计划", + "renameSession": "重命名会话", + "editAnswer": "编辑 {{question}} 的回答" }, "plugins": { "addItem": "添加项目", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 11bfa131b5..93b482f36f 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -4674,7 +4674,12 @@ "usingDefault": "使用預設", "whatToBuild": "您想建構什麼?", "whatToBuildPlaceholder": "例如,建構一個包含登入、註冊和密碼重設的使用者驗證系統...", - "agentClarification": "" + "agentClarification": "", + "nextQuestion": "下一個問題", + "answeredQuestions": "已回答的問題", + "runningPlan": "進行中的計畫", + "renameSession": "重新命名工作階段", + "editAnswer": "編輯 {{question}} 的回答" }, "plugins": { "addItem": "新增項目", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index bb2e95aa15..04728c78be 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -4557,6 +4557,11 @@ export default interface Resources { "breakingDown": "Breaking down...", "collapse": "Collapse", "continue": "Continue", + "nextQuestion": "Next question", + "answeredQuestions": "Answered questions", + "runningPlan": "Running plan", + "renameSession": "Rename session", + "editAnswer": "Edit answer for {{question}}", "createSingleTask": "Create Single Task", "createTasks": "Create Tasks", "creating": "Creating...",