FN-8026: fix idle reconnecting hints in interview modals

Keep reconnecting indicators scoped to active interview generation states.

- Hide transient reconnecting hints on persisted mission and milestone questions
- Limit subtask reconnecting hints to the generating state
- Cover active and persisted reconnecting behavior with modal tests
- Add a patch changeset for the dashboard fix

Files changed:
 .changeset/FN-8026-interview-reconnecting-hint.md  |  7 ++++
 .../components/MilestoneSliceInterviewModal.tsx    |  6 +++-
 .../app/components/MissionInterviewModal.tsx       |  6 +++-
 .../app/components/SubtaskBreakdownModal.tsx       |  6 +++-
 .../MilestoneSliceInterviewModal.test.tsx          | 37 ++++++++++++++++++++++
 .../__tests__/MissionInterviewModal.test.tsx       | 20 +++++-------
 .../__tests__/SubtaskBreakdownModal.test.tsx       | 19 ++++++-----
 7 files changed, 76 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-8026

Fusion-Task-Lineage: 60b2f741-6761-4fc0-bf50-cbddce5f5b6f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 23:39:12 -07:00
parent 513796b7e7
commit db05a77fea
7 changed files with 79 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Hide the interview reconnecting hint on persisted question and review screens.
category: fix
dev: Gate the shared reconnecting indicator in MissionInterviewModal/MilestoneSliceInterviewModal to view.type === "loading" and in SubtaskBreakdownModal to view.type === "generating", mirroring FN-8002 so idle awaiting-input screens render purely from persisted state.

View File

@@ -439,7 +439,11 @@ export function MilestoneSliceInterviewModal({
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">{t("interview.reconnecting", "Reconnecting…")}</div>}
{/*
FNXC:MilestoneSliceInterview 2026-07-15-00:00:
Awaiting-input questions render persisted database state, so transient idle SSE reconnects must not imply that the question is being regenerated. Reserve this hint for the active loading view, mirroring the FN-8002 Planning Mode invariant.
*/}
{isReconnecting && view.type === "loading" && <div className="form-hint text-muted">{t("interview.reconnecting", "Reconnecting…")}</div>}
{view.type === "initial" && (
<div className="planning-initial">

View File

@@ -811,7 +811,11 @@ export function MissionInterviewModal({
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">{t("missions.reconnecting", "Reconnecting…")}</div>}
{/*
FNXC:MissionInterviewModal 2026-07-15-00:00:
Awaiting-input questions render persisted database state, so transient idle SSE reconnects must not imply that the question is being regenerated. Reserve this hint for the active loading view, mirroring the FN-8002 Planning Mode invariant.
*/}
{isReconnecting && view.type === "loading" && <div className="form-hint text-muted">{t("missions.reconnecting", "Reconnecting…")}</div>}
{view.type === "initial" && (
<div className="planning-initial">

View File

@@ -613,7 +613,11 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">{t("subtasks.reconnecting", "Reconnecting…")}</div>}
{/*
FNXC:SubtaskBreakdown 2026-07-15-00:00:
The persisted editing stored-review screen and in-flight creating state render from state, so transient SSE reconnects must not imply regeneration. Reserve the shared hint for active generating, which retains its intentional inline indicator.
*/}
{isReconnecting && view.type === "generating" && <div className="form-hint text-muted">{t("subtasks.reconnecting", "Reconnecting…")}</div>}
{view.type === "initial" && (
<div className="planning-loading" data-testid="subtask-progress-state">

View File

@@ -393,6 +393,43 @@ describe("MilestoneSliceInterviewModal", () => {
expect(screen.getByText("Pick the size for this feature.")).toBeDefined();
});
});
it("shows reconnecting only during active generation, not on persisted questions", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => expect(streamHandlers).toBeDefined());
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
});
describe("summary and apply", () => {

View File

@@ -259,7 +259,7 @@ describe("MissionInterviewModal", () => {
expect(mockForceAcquireSessionLock).not.toHaveBeenCalled();
});
it("shows reconnecting indicator without clearing current question", async () => {
it("shows reconnecting only during active generation, not on persisted questions", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
@@ -273,26 +273,22 @@ describe("MissionInterviewModal", () => {
});
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
});
await waitFor(() => {
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
it("preserves streaming thinking output while reconnecting", async () => {
@@ -390,7 +386,7 @@ describe("MissionInterviewModal", () => {
streamHandlers.onError?.("Stream error");
});
expect(await screen.findByText("Reconnecting…")).toBeInTheDocument();
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
expect(screen.queryByText("Stream error")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();

View File

@@ -302,28 +302,27 @@ describe("SubtaskBreakdownModal", () => {
expect(screen.getByDisplayValue("Do second")).toBeInTheDocument();
});
it("shows reconnecting indicator without clearing subtask state", async () => {
it("shows reconnecting only during generation, not on persisted subtask review", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getAllByText("Reconnecting…")).toHaveLength(2);
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
});
expect(await screen.findByDisplayValue("First")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
await waitFor(() => {
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
});
expect(screen.getByDisplayValue("First")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
});
await waitFor(() => {
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
expect(screen.getByDisplayValue("First")).toBeInTheDocument();
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
it("preserves thinking output while reconnecting in generating state", async () => {