fix(dashboard): stream AI thinking on every Planning Mode generation step

The workspace loader that covers all follow-up turns (next question,
refine, contextual comments, question regeneration) showed only a spinner
and elapsed time — streamed thinking/output was visible only on the first
pre-summary turn. Reuse the initial loading view's thinking container and
toggle there, and mirror the generation-activity label instead of a
hardcoded "Generating plan…".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 18:39:10 -07:00
parent 7cb87e7687
commit e2ee8ba276
3 changed files with 58 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Every Planning Mode generation step now streams AI thinking/output, not just the first turn.
category: fix
dev: The planning workspace loader (follow-up turns — next question, refine, contextual comments, question regeneration) reuses the initial loading view's thinking container/toggle and mirrors the generation-activity label.

View File

@@ -3975,12 +3975,38 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{view.type === "loading" && (
<div className="planning-workspace-loader" role="status" aria-live="polite">
<Loader2 size={40} className="spin" />
<strong>{t("planning.generatingPlan", "Generating plan…")}</strong>
<strong>
{generationActivity === "question"
? t("planning.generatingQuestion", "Generating next question…")
: t("planning.generatingPlan", "Generating plan…")}
</strong>
{generationStartTime && <span>{t("planning.thinkingElapsed", "Thinking… ({{seconds}}s)", { seconds: elapsedSeconds })}</span>}
<button className="btn planning-stop-btn" type="button" onClick={() => void handleStopGeneration()}>
<StopCircle size={14} />
<span className="icon-ml-6">{t("planning.stop", "Stop")}</span>
</button>
{/*
FNXC:PlanningThinkingVisibility 2026-07-23-22:45:
Every Planning Mode generation step must stream the model's thinking/output to
the operator, not only the first (pre-summary) turn. This workspace loader
covers all follow-up turns — next question, refine, contextual comments, and
question regeneration — and previously showed only a spinner with elapsed
time. Reuse the same thinking container + toggle as the initial loading view.
*/}
<div className="planning-thinking-container">
<button
className="planning-thinking-toggle"
onClick={() => setShowThinking(!showThinking)}
type="button"
>
{showThinking ? t("planning.hideThinking", "Hide thinking") : t("planning.showThinking", "Show thinking")}
</button>
{showThinking && streamingOutput && (
<div className="planning-thinking-output" ref={thinkingOutputRef}>
<pre>{streamingOutput}</pre>
</div>
)}
</div>
</div>
)}
</div>

View File

@@ -842,6 +842,30 @@ describe("PlanningModeModal sequential flow", () => {
expect(await screen.findByText("Thinking… (7s)")).toBeInTheDocument();
dateNow.mockRestore();
});
/*
FNXC:PlanningThinkingVisibility 2026-07-23-22:45:
Every generation step must stream thinking/output to the operator. Follow-up turns render
the workspace loader (summary present), which previously showed only a spinner + elapsed
time; this pins the streamed thinking pane there too.
*/
it("streams thinking in the workspace loader during follow-up generations", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "generating",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }),
});
renderSession();
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object)));
const handlers = mockConnectPlanningStream.mock.calls[0]?.[2];
act(() => handlers?.onThinking?.("Weighing the tradeoffs between approaches…"));
expect(await screen.findByText("Weighing the tradeoffs between approaches…")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Hide thinking" }));
expect(screen.queryByText("Weighing the tradeoffs between approaches…")).toBeNull();
});
it("returns to the prior question without an error when generation is stopped", async () => {
const priorQuestion = { id: "q-prior", type: "text", question: "What should change?" };
mockFetchAiSession.mockResolvedValue({