feat(FN-3078): merge fusion/fn-3078

This merge lands four features: a `/clear` command for Chat and Quick Chat with session banner fixes (FN-3062), a todo view redesign with restructured rows and improved styling (FN-3063), deterministic peer exchange shutdown with dashboard improvements (FN-3040), and a summary Q&A disclosure feature

Fusion-Task-Id: FN-3078
This commit is contained in:
Fusion
2026-05-01 13:37:03 -07:00
committed by gsxdsm
parent 8b7f20f207
commit 0188da703b
9 changed files with 156 additions and 14 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Raise the per-IP planning session rate limit from 5/hour to 1000/hour. The previous cap was tripping for normal interactive usage during a single session.

View File

@@ -1599,7 +1599,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
clearThinking();
if (err instanceof RateLimitError) {
console.error("\n Rate limit exceeded. Maximum 5 planning sessions per hour.\n");
console.error("\n Rate limit exceeded. Maximum 1000 planning sessions per hour.\n");
process.exit(1);
}

View File

@@ -2317,7 +2317,7 @@ describe("Planning Mode API", () => {
it("throws on rate limit error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 5 planning sessions per hour." }, 429)
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 1000 planning sessions per hour." }, 429)
);
await expect(startPlanning("Build something")).rejects.toThrow("Rate limit exceeded");

View File

@@ -776,6 +776,14 @@
gap: 20px;
}
.planning-summary-qa-disclosure {
margin-top: 0;
}
.planning-summary-qa-disclosure .onboarding-disclosure-content {
padding-inline-start: 0;
}
.planning-summary-form {
display: flex;
flex-direction: column;

View File

@@ -36,6 +36,7 @@ import {
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { getSessionTabId } from "../utils/getSessionTabId";
@@ -1731,10 +1732,10 @@ function SummaryView({
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{historyEntries.length > 0 && (
<>
<OnboardingDisclosure summary="Show user Q&A" className="planning-summary-qa-disclosure">
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
</OnboardingDisclosure>
)}
<div className="planning-summary-header">

View File

@@ -963,6 +963,134 @@ describe("PlanningModeModal", () => {
});
describe("Conversation history", () => {
it("hides completed-session Q&A by default behind a summary disclosure", async () => {
const resumedSummary: PlanningSummary = {
title: "Summary with hidden history",
description: "Recovered summary description",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Deliverable A"],
};
const restoredHistory = [
{
question: {
id: "q1",
type: "single_select",
question: "What scope do you need?",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
],
},
response: { q1: "medium" },
thinkingOutput: "Reasoning for scope question",
},
];
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-with-history",
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-complete-with-history"
/>,
);
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
expect(screen.getByRole("button", { name: "Show user Q&A" })).toBeDefined();
expect(screen.queryByTestId("conversation-history")).toBeNull();
expect(screen.queryByText("What scope do you need?")).toBeNull();
expect(screen.queryByText("Medium")).toBeNull();
});
it("reveals completed-session Q&A when summary disclosure is expanded", async () => {
const resumedSummary: PlanningSummary = {
title: "Summary with expandable history",
description: "Recovered summary description",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Deliverable A"],
};
const restoredHistory = [
{
question: {
id: "q1",
type: "single_select",
question: "What scope do you need?",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
],
},
response: { q1: "medium" },
thinkingOutput: "Reasoning for scope question",
},
];
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-with-history-2",
type: "planning",
status: "complete",
title: resumedSummary.title,
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-complete-with-history-2"
/>,
);
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Show user Q&A" }));
await waitFor(() => {
expect(screen.getByTestId("conversation-history")).toBeDefined();
});
expect(screen.getByText("What scope do you need?")).toBeDefined();
expect(within(screen.getByTestId("conversation-history")).getByText("Medium")).toBeDefined();
});
it("restores all persisted Q&A pairs when resuming a session", async () => {
mockConnectPlanningStream.mockImplementationOnce(() => ({
close: vi.fn(),

View File

@@ -311,12 +311,12 @@ describe("planning module", () => {
it("enforces rate limiting", async () => {
const mockIp = getUniqueIp();
// Create max sessions (5 per hour)
for (let i = 0; i < 5; i++) {
// Create max sessions (1000 per hour)
for (let i = 0; i < 1000; i++) {
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
}
// 6th session should fail
// 1001st session should fail
await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow(RateLimitError);
});
@@ -325,7 +325,7 @@ describe("planning module", () => {
try {
const mockIp = getUniqueIp();
// Create max sessions
for (let i = 0; i < 5; i++) {
for (let i = 0; i < 1000; i++) {
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
}

View File

@@ -9987,9 +9987,9 @@ describe("Planning Mode Routes", () => {
expect(res.body.sessionId).toBeDefined();
});
it("enforces rate limiting (5 sessions per hour per IP)", async () => {
// Create 5 sessions (should succeed)
for (let i = 0; i < 5; i++) {
it("enforces rate limiting (1000 sessions per hour per IP)", async () => {
// Create 1000 sessions (should succeed)
for (let i = 0; i < 1000; i++) {
const res = await REQUEST(
buildApp(),
"POST",
@@ -10000,12 +10000,12 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(201);
}
// 6th session should be rate limited
// 1001st session should be rate limited
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Plan 6" }),
JSON.stringify({ initialPlan: "Plan 1001" }),
{ "Content-Type": "application/json" }
);

View File

@@ -213,7 +213,7 @@ export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
/** Max planning sessions per IP per hour */
const MAX_SESSIONS_PER_IP_PER_HOUR = 5;
const MAX_SESSIONS_PER_IP_PER_HOUR = 1000;
/** Rate limiting window in milliseconds (1 hour) */
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;