FN-210: show initiating prompt in Planning Mode history
Expose the prompt that started a Planning Mode session wherever its history is reviewed. - Add a reusable read-only PlanningSessionPrompt display for history, errors, and plan-review Q&A. - Integrate prompt rendering and responsive styling into PlanningModeModal. - Document the Planning Mode history behavior and add a minor release changeset. - Cover populated, missing, long, and responsive prompt states with dashboard tests. Files changed: .changeset/fn-210-planning-session-prompt.md | 7 + docs/dashboard-guide.md | 3 + .../dashboard/app/components/PlanningModeModal.css | 9 + .../dashboard/app/components/PlanningModeModal.tsx | 26 +- .../app/components/PlanningSessionPrompt.css | 29 +++ .../app/components/PlanningSessionPrompt.tsx | 37 +++ .../PlanningModeModal.session-prompt.test.tsx | 280 +++++++++++++++++++++ .../__tests__/PlanningSessionPrompt.test.tsx | 48 +++ 8 files changed, 435 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-210 Fusion-Task-Lineage: 08201db3-7b1b-4567-95e0-1e97dc3a7ae6 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-210-planning-session-prompt.md
Normal file
7
.changeset/fn-210-planning-session-prompt.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Planning Mode history now shows the original prompt that started the session, read-only.
|
||||
category: feature
|
||||
dev: Adds PlanningSessionPrompt to History, error, and plan-review Q&A surfaces.
|
||||
@@ -683,6 +683,9 @@ When an active Planning AI generation appears stuck, Planning Mode automatically
|
||||
<!-- FNXC:PlanningMode 2026-07-19-15:55: FN-8400 replaces the duplicate prompt-recovery controls with a focused three-pane interview; restarting remains a deliberate New session action. -->
|
||||
Use **New session** to restart planning with a different idea.
|
||||
|
||||
<!-- FNXC:PlanningHistory 2026-08-28-03:34: FN-210 requires every Planning Mode Q&A history surface to preserve the initiating request as visible, read-only session context. -->
|
||||
The **History** panel, the error panel, and plan review's **Show user Q&A** section show the original request that started the session in a read-only box above the questions and answers. Reading, scrolling, selecting, or copying this text does not edit it or change the session. When an older or incomplete session has no saved starting text, Planning Mode omits the box entirely.
|
||||
|
||||
<!-- FNXC:PlanningMode 2026-07-23-11:50: Planning Mode must preserve a collaborative discovery identity instead of inheriting triage/workflow execution specifications. -->
|
||||
Planning Mode uses a dedicated collaborative system prompt. It investigates relevant repository and board context, explores assumptions and tradeoffs, considers decomposition choices without automatically creating child tasks, and sharpens concrete deliverables with observable acceptance criteria. This is separate from task triage and the executor-ready **PROMPT.md** generated after task creation. An explicitly configured nonblank `planning-system` prompt override replaces the complete dedicated system prompt; blank or absent values retain the default interview.
|
||||
|
||||
|
||||
@@ -248,6 +248,10 @@ The embedded planning header must be a visual SIBLING of MissionManager's inline
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.planning-history-scroll .planning-session-prompt {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.planning-history-scroll .conversation-history {
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
@@ -2338,6 +2342,11 @@ mobile and desktop offer the same View task / Return to sessions choices.
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.planning-history-scroll .planning-session-prompt {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-21-18:34:
|
||||
Mobile question and plan-review panes were double-inset: workspace gutter + scroll padding + nested card chrome on top of the pane frame. Drop the outer gutter and pane chrome so content sits flush edge-to-edge like desktop, keep a light tab strip inset, and leave readable content padding on the question panel / plan document only. Clear the desktop column divider so a solo question/plan card is not edged by a right border.
|
||||
|
||||
@@ -56,6 +56,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore, Pencil, History } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { PlanningSessionPrompt } from "./PlanningSessionPrompt";
|
||||
import { ThinkingTrace } from "./ThinkingTrace";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { OnboardingDisclosure } from "./OnboardingDisclosure";
|
||||
@@ -445,8 +446,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
/*
|
||||
FNXC:Planning 2026-07-15-00:00:
|
||||
FN-8003 keeps the started prompt separate from the editable composer so users can recover the original idea when an interview errors or drifts off track. The composer may be reset or reused, but this value belongs only to the active session.
|
||||
|
||||
FNXC:PlanningHistory 2026-08-28-03:34:
|
||||
FN-210 renders the active session's initiating prompt read-only wherever Planning Mode presents its question-and-answer history.
|
||||
*/
|
||||
const [_activePlanPrompt, setActivePlanPrompt] = useState("");
|
||||
const [activePlanPrompt, setActivePlanPrompt] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// FNXC:PlanningMultiTask 2026-08-03-18:32: Latest task created from this plan. Passing its id with the next explicit Proceed action
|
||||
@@ -2003,12 +2007,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
typed or selected local answer before its enabled Next action can submit it. A different
|
||||
session still takes the neutral loader so its prior turn never remains visible.
|
||||
*/
|
||||
const preservesActiveWorkspace = currentSessionIdRef.current === sessionId
|
||||
const isDifferentSession = currentSessionIdRef.current !== sessionId;
|
||||
const preservesActiveWorkspace = !isDifferentSession
|
||||
&& (viewRef.current.type === "question" || viewRef.current.type === "plan_review");
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
/*
|
||||
FNXC:PlanningHistory 2026-08-28-03:47:
|
||||
The History overlay remains mounted while an externally selected session loads. Clear the
|
||||
initiating prompt at a different-session load boundary so the prior session's prompt cannot
|
||||
appear under the new selection, while same-session refreshes retain their visible prompt.
|
||||
*/
|
||||
if (isDifferentSession) setActivePlanPrompt("");
|
||||
setError(null);
|
||||
/*
|
||||
FNXC:PlanningMultiTask 2026-07-24-01:40:
|
||||
@@ -2988,10 +3000,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
});
|
||||
} else {
|
||||
draftSessionIdRef.current = sessionId;
|
||||
setInitialPlan(_activePlanPrompt);
|
||||
setInitialPlan(activePlanPrompt);
|
||||
setView({ type: "initial" });
|
||||
}
|
||||
}, [_activePlanPrompt, projectId, workspaceQuestion]);
|
||||
}, [activePlanPrompt, projectId, workspaceQuestion]);
|
||||
|
||||
const handleRetryFromError = useCallback(async () => {
|
||||
if (view.type !== "error") {
|
||||
@@ -3685,6 +3697,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</button>
|
||||
</div>
|
||||
<div className="planning-history-scroll">
|
||||
<PlanningSessionPrompt prompt={activePlanPrompt} testId="planning-history-initial-prompt" />
|
||||
{historyPanelEntries.length > 0 ? (
|
||||
// FNXC:PlanningHistory 2026-07-20-23:24: FN-8449 keeps history thinking collapsed so operators can scan Q&A first; the existing toggle remains available to expand it, matching the FN-7974 chat default.
|
||||
<ConversationHistory entries={historyPanelEntries} />
|
||||
@@ -3979,6 +3992,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
{view.type === "error" && (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
<PlanningSessionPrompt prompt={activePlanPrompt} testId="planning-error-initial-prompt" />
|
||||
{conversationHistory.length > 0 && (
|
||||
<>
|
||||
<ConversationHistory entries={conversationHistory} />
|
||||
@@ -4225,6 +4239,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
projectId={projectId}
|
||||
summary={editedSummary}
|
||||
historyEntries={conversationHistory}
|
||||
initialPrompt={activePlanPrompt}
|
||||
onSummaryChange={setEditedSummary}
|
||||
tasks={tasks}
|
||||
branchMode={branchMode}
|
||||
@@ -4664,6 +4679,7 @@ interface SummaryViewProps {
|
||||
projectId?: string;
|
||||
summary: PlanningSummary;
|
||||
historyEntries: ConversationHistoryEntry[];
|
||||
initialPrompt?: string;
|
||||
onSummaryChange: (summary: PlanningSummary) => void;
|
||||
tasks: Task[];
|
||||
branchMode: "project-default" | "auto-new" | "existing" | "custom-new";
|
||||
@@ -4684,6 +4700,7 @@ export function SummaryView({
|
||||
projectId,
|
||||
summary: rawSummary,
|
||||
historyEntries,
|
||||
initialPrompt,
|
||||
onSummaryChange,
|
||||
tasks,
|
||||
branchMode,
|
||||
@@ -4743,6 +4760,7 @@ export function SummaryView({
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
{historyEntries.length > 0 && (
|
||||
<OnboardingDisclosure summary={t("planning.showQA", "Show user Q&A")} className="planning-summary-qa-disclosure">
|
||||
<PlanningSessionPrompt prompt={initialPrompt} testId="planning-summary-initial-prompt" />
|
||||
<ConversationHistory entries={historyEntries} />
|
||||
<div className="conversation-separator" />
|
||||
</OnboardingDisclosure>
|
||||
|
||||
29
packages/dashboard/app/components/PlanningSessionPrompt.css
Normal file
29
packages/dashboard/app/components/PlanningSessionPrompt.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.planning-session-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.planning-session-prompt-label {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.planning-session-prompt-text {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--space-md);
|
||||
border: solid var(--btn-border-width) color-mix(in srgb, var(--border) 82%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
cursor: default;
|
||||
}
|
||||
37
packages/dashboard/app/components/PlanningSessionPrompt.tsx
Normal file
37
packages/dashboard/app/components/PlanningSessionPrompt.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import "./PlanningSessionPrompt.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
|
||||
|
||||
interface PlanningSessionPromptProps {
|
||||
prompt?: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PlanningHistory 2026-08-28-03:34:
|
||||
* Planning Mode session history shows the operator's initiating prompt read-only next to the Q&A. Sessions without a saved prompt render nothing instead of leaving an empty history shell.
|
||||
*/
|
||||
export function PlanningSessionPrompt({ prompt, testId }: PlanningSessionPromptProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { ref } = useAutosizeTextarea({ value: prompt ?? "", minHeight: 64, maxHeight: 240 });
|
||||
|
||||
if (!prompt?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t("planning.initialPromptLabel", "Prompt that started this session");
|
||||
|
||||
return (
|
||||
<section className="planning-session-prompt">
|
||||
<span className="planning-session-prompt-label">{label}</span>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="planning-session-prompt-text"
|
||||
readOnly
|
||||
value={prompt}
|
||||
data-testid={testId}
|
||||
aria-label={label}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlanningModeModal, SummaryView, resetPlanningAutoRetryAttemptsForTests } from "../PlanningModeModal";
|
||||
import {
|
||||
mockCreatePlanningDraft,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockFetchAiSession,
|
||||
mockFetchAiSessions,
|
||||
mockRespondToPlanning,
|
||||
mockRetryPlanningSession,
|
||||
mockStartPlanningStreaming,
|
||||
mockStopPlanningGeneration,
|
||||
mockSummary,
|
||||
mockTasks,
|
||||
mockValidatePlanningSession,
|
||||
} from "./PlanningModeModal.test-helpers";
|
||||
|
||||
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "tablet" | "mobile"));
|
||||
const mockUpdatePlanningSessionDraft = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../hooks/useToast", () => ({ useOptionalToast: () => null, useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }) }));
|
||||
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px)", isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => mockViewportMode(), isMobileViewport: () => mockViewportMode() === "mobile", isTabletTouchViewport: (mode?: string) => mode === "tablet", useViewportMode: () => mockViewportMode() }));
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }) }));
|
||||
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
|
||||
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => undefined) }));
|
||||
vi.mock("../../api", () => {
|
||||
const fn = vi.fn;
|
||||
return {
|
||||
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
|
||||
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
|
||||
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: fn(), rewindPlanningSession: fn(), retryPlanningSession: (...args: unknown[]) => mockRetryPlanningSession(...args), cancelPlanning: fn(), stopPlanningGeneration: (...args: unknown[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: (...args: unknown[]) => mockUpdatePlanningSessionDraft(...args), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), archiveAiSession: fn(), unarchiveAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
|
||||
};
|
||||
});
|
||||
|
||||
const history = [{
|
||||
question: { id: "scope", type: "text", question: "What should change?" },
|
||||
response: { scope: "Rework billing" },
|
||||
}];
|
||||
|
||||
const sessionBase = {
|
||||
id: "session-1",
|
||||
title: "Billing plan",
|
||||
projectId: "project-1",
|
||||
updatedAt: "2026-08-28T00:00:00.000Z",
|
||||
archived: false,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "next", type: "text", question: "What comes next?" }),
|
||||
result: JSON.stringify(mockSummary),
|
||||
inputPayload: "{}",
|
||||
conversationHistory: JSON.stringify(history),
|
||||
thinkingOutput: "",
|
||||
};
|
||||
|
||||
const modalProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onTaskCreated: vi.fn(),
|
||||
onTasksCreated: vi.fn(),
|
||||
tasks: mockTasks,
|
||||
projectId: "project-1",
|
||||
};
|
||||
|
||||
function renderSession(sessionId = "session-1") {
|
||||
return render(<PlanningModeModal {...modalProps} resumeSessionId={sessionId} />);
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
fireEvent.click(await screen.findByRole("button", { name: "History" }));
|
||||
return screen.findByTestId("planning-history-overlay");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
resetPlanningAutoRetryAttemptsForTests();
|
||||
mockViewportMode.mockReturnValue("desktop");
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Billing plan" });
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true });
|
||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||
mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true });
|
||||
mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-210" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
resetPlanningAutoRetryAttemptsForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Planning Mode initiating prompt history", () => {
|
||||
it("shows the exact prompt alongside populated desktop history", async () => {
|
||||
const prompt = "Rework the billing page\nwith usage tiers";
|
||||
mockFetchAiSession.mockResolvedValue({ ...sessionBase, inputPayload: JSON.stringify({ initialPlan: prompt }) });
|
||||
renderSession();
|
||||
|
||||
const overlay = within(await openHistory());
|
||||
const textarea = overlay.getByTestId("planning-history-initial-prompt");
|
||||
expect(textarea).toHaveValue(prompt);
|
||||
expect(textarea).toHaveProperty("readOnly", true);
|
||||
expect(overlay.getByTestId("conversation-history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the prompt together with the empty-history state", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...sessionBase,
|
||||
conversationHistory: "[]",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Keep the starting idea visible" }),
|
||||
});
|
||||
renderSession();
|
||||
|
||||
const overlay = within(await openHistory());
|
||||
expect(overlay.getByTestId("planning-history-initial-prompt")).toHaveValue("Keep the starting idea visible");
|
||||
expect(overlay.getByText("No history yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an absent prompt", "{}"],
|
||||
["a whitespace-only prompt", JSON.stringify({ initialPlan: " \n " })],
|
||||
])("renders no prompt shell for %s", async (_label, inputPayload) => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...sessionBase, inputPayload });
|
||||
renderSession();
|
||||
|
||||
const overlay = within(await openHistory());
|
||||
expect(overlay.queryByTestId("planning-history-initial-prompt")).toBeNull();
|
||||
expect(overlay.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a malformed payload while preserving Q&A", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...sessionBase, inputPayload: "not-json" });
|
||||
renderSession();
|
||||
|
||||
const overlay = within(await openHistory());
|
||||
expect(overlay.queryByTestId("planning-history-initial-prompt")).toBeNull();
|
||||
expect(overlay.queryByRole("textbox")).toBeNull();
|
||||
expect(overlay.getByTestId("conversation-history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the exact prompt in the mobile history panel", async () => {
|
||||
const prompt = "Rework the billing page\nwith usage tiers";
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchAiSession.mockResolvedValue({ ...sessionBase, inputPayload: JSON.stringify({ initialPlan: prompt }) });
|
||||
renderSession();
|
||||
|
||||
expect(within(await openHistory()).getByTestId("planning-history-initial-prompt")).toHaveValue(prompt);
|
||||
});
|
||||
|
||||
it("shows distinct prompt boxes in the error pane and its open history overlay", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...sessionBase,
|
||||
status: "error",
|
||||
currentQuestion: null,
|
||||
error: "The planning stream was interrupted",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover this idea" }),
|
||||
});
|
||||
mockRetryPlanningSession.mockRejectedValue(new Error("Retry remains unavailable"));
|
||||
renderSession();
|
||||
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3));
|
||||
const errorPrompt = await screen.findByTestId("planning-error-initial-prompt");
|
||||
expect(errorPrompt).toHaveValue("Recover this idea");
|
||||
|
||||
const overlayPrompt = within(await openHistory()).getByTestId("planning-history-initial-prompt");
|
||||
expect(overlayPrompt).toHaveValue("Recover this idea");
|
||||
expect(screen.getAllByTestId("planning-history-initial-prompt")).toHaveLength(1);
|
||||
expect(screen.getAllByTestId("planning-error-initial-prompt")).toHaveLength(1);
|
||||
expect(overlayPrompt).not.toBe(errorPrompt);
|
||||
});
|
||||
|
||||
it("clears the prior prompt when switching sessions", async () => {
|
||||
const sessions = new Map([
|
||||
["session-a", { ...sessionBase, id: "session-a", title: "Session A", inputPayload: JSON.stringify({ initialPlan: "Prompt A" }) }],
|
||||
["session-b-empty", { ...sessionBase, id: "session-b-empty", title: "Session B empty", inputPayload: "{}" }],
|
||||
["session-b-prompt", { ...sessionBase, id: "session-b-prompt", title: "Session B prompt", inputPayload: JSON.stringify({ initialPlan: "Prompt B" }) }],
|
||||
]);
|
||||
mockFetchAiSession.mockImplementation((sessionId: string) => Promise.resolve(sessions.get(sessionId)));
|
||||
|
||||
const view = renderSession("session-a");
|
||||
expect(within(await openHistory()).getByTestId("planning-history-initial-prompt")).toHaveValue("Prompt A");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close history" }));
|
||||
|
||||
view.rerender(<PlanningModeModal {...modalProps} resumeSessionId="session-b-empty" />);
|
||||
await screen.findByText("Session B empty");
|
||||
expect(within(await openHistory()).queryByTestId("planning-history-initial-prompt")).toBeNull();
|
||||
expect(screen.queryByText("Prompt A")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close history" }));
|
||||
|
||||
view.rerender(<PlanningModeModal {...modalProps} resumeSessionId="session-b-prompt" />);
|
||||
await screen.findByText("Session B prompt");
|
||||
expect(within(await openHistory()).getByTestId("planning-history-initial-prompt")).toHaveValue("Prompt B");
|
||||
expect(screen.queryByText("Prompt A")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the prior prompt while a different session is still loading", async () => {
|
||||
const sessionA = {
|
||||
...sessionBase,
|
||||
id: "session-a",
|
||||
title: "Session A",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Prompt A" }),
|
||||
};
|
||||
const sessionB = {
|
||||
...sessionBase,
|
||||
id: "session-b",
|
||||
title: "Session B",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Prompt B" }),
|
||||
};
|
||||
let resolveSessionB!: (session: typeof sessionB) => void;
|
||||
const deferredSessionB = new Promise<typeof sessionB>((resolve) => {
|
||||
resolveSessionB = resolve;
|
||||
});
|
||||
mockFetchAiSession.mockImplementation((sessionId: string) => (
|
||||
sessionId === "session-a" ? Promise.resolve(sessionA) : deferredSessionB
|
||||
));
|
||||
|
||||
const view = renderSession("session-a");
|
||||
const overlay = within(await openHistory());
|
||||
expect(overlay.getByTestId("planning-history-initial-prompt")).toHaveValue("Prompt A");
|
||||
|
||||
view.rerender(<PlanningModeModal {...modalProps} resumeSessionId="session-b" />);
|
||||
|
||||
await waitFor(() => expect(mockFetchAiSession).toHaveBeenCalledWith("session-b"));
|
||||
await waitFor(() => {
|
||||
expect(overlay.queryByTestId("planning-history-initial-prompt")).toBeNull();
|
||||
expect(overlay.queryByDisplayValue("Prompt A")).toBeNull();
|
||||
});
|
||||
|
||||
resolveSessionB(sessionB);
|
||||
expect(await overlay.findByTestId("planning-history-initial-prompt")).toHaveValue("Prompt B");
|
||||
});
|
||||
|
||||
it("does not mutate the prompt or planning draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
const prompt = "Rework the billing page\nwith usage tiers";
|
||||
mockFetchAiSession.mockResolvedValue({ ...sessionBase, inputPayload: JSON.stringify({ initialPlan: prompt }) });
|
||||
renderSession();
|
||||
|
||||
const textarea = within(await openHistory()).getByTestId("planning-history-initial-prompt");
|
||||
await user.type(textarea, "edit");
|
||||
|
||||
expect(textarea).toHaveValue(prompt);
|
||||
expect(mockUpdatePlanningSessionDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the prompt alongside Q&A in the summary disclosure and omits a blank prompt", async () => {
|
||||
const user = userEvent.setup();
|
||||
const summaryProps = {
|
||||
projectId: "project-1",
|
||||
summary: mockSummary,
|
||||
historyEntries: history,
|
||||
onSummaryChange: vi.fn(),
|
||||
tasks: [],
|
||||
branchMode: "project-default" as const,
|
||||
branchName: "",
|
||||
baseBranch: "main",
|
||||
onBranchModeChange: vi.fn(),
|
||||
onBranchNameChange: vi.fn(),
|
||||
onBaseBranchChange: vi.fn(),
|
||||
onCreateTask: vi.fn(),
|
||||
isCreatingTask: false,
|
||||
isRefiningSummary: false,
|
||||
};
|
||||
const view = render(<SummaryView {...summaryProps} initialPrompt="Summary-stage prompt" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show user Q&A" }));
|
||||
expect(screen.getByTestId("planning-summary-initial-prompt")).toHaveValue("Summary-stage prompt");
|
||||
expect(screen.getByTestId("conversation-history")).toBeInTheDocument();
|
||||
|
||||
view.unmount();
|
||||
render(<SummaryView {...summaryProps} />);
|
||||
await user.click(screen.getByRole("button", { name: "Show user Q&A" }));
|
||||
expect(screen.queryByTestId("planning-summary-initial-prompt")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PlanningSessionPrompt } from "../PlanningSessionPrompt";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PlanningSessionPrompt", () => {
|
||||
it("renders the initiating prompt in a read-only textarea", () => {
|
||||
render(<PlanningSessionPrompt prompt="Ship the new billing page" testId="x" />);
|
||||
|
||||
const textarea = screen.getByTestId("x");
|
||||
expect(textarea.tagName).toBe("TEXTAREA");
|
||||
expect(textarea).toHaveValue("Ship the new billing page");
|
||||
expect(textarea).toHaveProperty("readOnly", true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty prompt", ""],
|
||||
["a whitespace-only prompt", " \n\t "],
|
||||
["an undefined prompt", undefined],
|
||||
])("renders no shell for %s", (_label, prompt) => {
|
||||
const { container } = render(<PlanningSessionPrompt prompt={prompt} testId="x" />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByTestId("x")).toBeNull();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("cannot be changed through user input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanningSessionPrompt prompt="Keep this exact prompt" testId="x" />);
|
||||
|
||||
const textarea = screen.getByTestId("x");
|
||||
await user.type(textarea, "mutation");
|
||||
|
||||
expect(textarea).toHaveValue("Keep this exact prompt");
|
||||
});
|
||||
|
||||
it("preserves a multi-line prompt verbatim", () => {
|
||||
const prompt = "line one\nline two\nline three";
|
||||
render(<PlanningSessionPrompt prompt={prompt} testId="x" />);
|
||||
|
||||
expect(screen.getByTestId("x")).toHaveValue(prompt);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user