feat(FN-3229): add async planning draft sync to avoid blocking while typing

Merged two changes: a new feature (FN-3229) that syncs planning drafts to the server in real-time while the user types, adding a PlanningModeModal component, a new ai-session-store for managing session state, and a dashboard API route to persist drafts; plus a performance fix replacing a blocking `e

Fusion-Task-Id: FN-3229
This commit is contained in:
Fusion
2026-05-02 19:21:38 -07:00
committed by gsxdsm
parent 27fb55700d
commit 23fc5a0fc4
6 changed files with 199 additions and 0 deletions

View File

@@ -7235,6 +7235,17 @@ export function pingSession(sessionId: string, projectId?: string): Promise<{ ok
});
}
export function updatePlanningSessionDraft(
sessionId: string,
draft: { title: string; initialPlan: string },
projectId?: string,
): Promise<{ ok: boolean }> {
return api<{ ok: boolean }>(withProjectId(`/ai-sessions/${encodeURIComponent(sessionId)}/draft`, projectId), {
method: "PATCH",
body: JSON.stringify(draft),
});
}
// ── Messages API ──────────────────────────────────────────────────────────
/** Response shape for GET /messages/inbox */

View File

@@ -20,6 +20,7 @@ import {
fetchModels,
cancelPlanning,
stopPlanningGeneration,
updatePlanningSessionDraft,
updateGlobalSettings,
type PlanningSession,
type SubtaskItem,
@@ -165,6 +166,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// target is the overlay and would dismiss the modal mid-resize.
const overlayMouseDownOnSelfRef = useRef(false);
const thinkingOutputRef = useRef<HTMLDivElement>(null);
const draftSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSyncedDraftRef = useRef<{ sessionId: string; initialPlan: string } | null>(null);
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
@@ -761,6 +764,85 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setMobileShowDetail(false);
}, []);
const syncPlanningDraft = useCallback(
async (sessionId: string, planText: string) => {
const trimmedPlan = planText.trim();
if (!trimmedPlan) {
return;
}
const alreadySynced =
lastSyncedDraftRef.current?.sessionId === sessionId &&
lastSyncedDraftRef.current.initialPlan === trimmedPlan;
if (alreadySynced) {
return;
}
try {
await updatePlanningSessionDraft(
sessionId,
{
title: trimmedPlan,
initialPlan: trimmedPlan,
},
projectId,
);
lastSyncedDraftRef.current = { sessionId, initialPlan: trimmedPlan };
} catch {
// best-effort draft sync; avoid blocking typing UX on transient failures
}
},
[projectId],
);
useEffect(() => {
if (draftSyncTimerRef.current) {
clearTimeout(draftSyncTimerRef.current);
draftSyncTimerRef.current = null;
}
if (!isOpen || view.type !== "initial" || !selectedSessionId) {
return;
}
draftSyncTimerRef.current = setTimeout(() => {
void syncPlanningDraft(selectedSessionId, initialPlan);
}, 500);
return () => {
if (draftSyncTimerRef.current) {
clearTimeout(draftSyncTimerRef.current);
draftSyncTimerRef.current = null;
}
};
}, [initialPlan, isOpen, selectedSessionId, syncPlanningDraft, view.type]);
useEffect(() => {
lastSyncedDraftRef.current = null;
}, [selectedSessionId]);
useEffect(() => {
return () => {
if (draftSyncTimerRef.current) {
clearTimeout(draftSyncTimerRef.current);
draftSyncTimerRef.current = null;
}
};
}, []);
const handleInitialPlanBlur = useCallback(() => {
if (draftSyncTimerRef.current) {
clearTimeout(draftSyncTimerRef.current);
draftSyncTimerRef.current = null;
}
if (!selectedSessionId || view.type !== "initial") {
return;
}
void syncPlanningDraft(selectedSessionId, initialPlan);
}, [initialPlan, selectedSessionId, syncPlanningDraft, view.type]);
const handleDeleteSession = useCallback(
async (sessionId: string) => {
const isActiveServerSession = (status: AiSessionSummary["status"]) =>

View File

@@ -16,6 +16,7 @@ const mockRespondToPlanning = vi.fn();
const mockRetryPlanningSession = vi.fn();
const mockCancelPlanning = vi.fn();
const mockStopPlanningGeneration = vi.fn();
const mockUpdatePlanningSessionDraft = vi.fn();
const mockCreateTaskFromPlanning = vi.fn();
const mockStartPlanningBreakdown = vi.fn();
const mockCreateTasksFromPlanning = vi.fn();
@@ -45,6 +46,7 @@ vi.mock("../../api", () => ({
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
@@ -221,6 +223,7 @@ describe("PlanningModeModal", () => {
mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue(undefined);
mockCancelPlanning.mockResolvedValue(undefined);
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
mockStopPlanningGeneration.mockResolvedValue({ success: true });
// Default: simulate receiving a question after a brief delay

View File

@@ -469,6 +469,38 @@ describe("AiSessionStore", () => {
expect(store.updateStatus("S-missing", "error", "Nope")).toBe(false);
});
it("updateDraft updates planning title + input payload and emits updated", () => {
seedSession({ id: "S-draft", status: "awaiting_input" });
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
const updated = store.updateDraft("S-draft", {
title: " Refined plan title ",
initialPlan: " Refined draft body ",
});
expect(updated).toBe(true);
const session = store.get("S-draft");
expect(session?.title).toBe("Refined plan title");
expect(session?.inputPayload).toBe(JSON.stringify({ initialPlan: "Refined draft body" }));
expect(onUpdated).toHaveBeenCalledWith(
expect.objectContaining({ id: "S-draft", title: "Refined plan title" }),
);
});
it("updateDraft returns false for missing or non-planning sessions", () => {
seedSession({ id: "S-subtask", status: "awaiting_input" });
db.prepare("UPDATE ai_sessions SET type = 'subtask' WHERE id = ?").run("S-subtask");
expect(
store.updateDraft("S-subtask", { title: "Nope", initialPlan: "Nope" }),
).toBe(false);
expect(
store.updateDraft("S-missing", { title: "Missing", initialPlan: "Missing" }),
).toBe(false);
});
it("listRecoverable returns awaiting_input and generating sessions", () => {
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });

View File

@@ -219,6 +219,36 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
return true;
}
/**
* Update persisted draft metadata for a planning session.
* Keeps sidebar title/input payload current while the user edits.
*/
updateDraft(id: string, draft: { title: string; initialPlan: string }): boolean {
const now = new Date().toISOString();
const trimmedTitle = draft.title.trim();
const trimmedPlan = draft.initialPlan.trim();
const inputPayload = JSON.stringify({ initialPlan: trimmedPlan });
const result = this.db
.prepare(
`UPDATE ai_sessions
SET title = ?, inputPayload = ?, updatedAt = ?
WHERE id = ? AND type = 'planning'`,
)
.run(trimmedTitle, inputPayload, now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (!changed) {
return false;
}
const row = this.get(id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return true;
}
/**
* Lightweight heartbeat for active sessions.
* Updates only `updatedAt` and intentionally does NOT emit

View File

@@ -3617,6 +3617,47 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.json({ ok: true });
});
/**
* PATCH /api/ai-sessions/:id/draft
* Keep planning draft title/text synchronized while editing.
* Body: { title: string, initialPlan: string }
*/
router.patch("/ai-sessions/:id/draft", (req, res) => {
if (!aiSessionStore) {
throw notFound("AI sessions not available");
}
const { id } = req.params;
const session = aiSessionStore.get(id);
if (!session) {
throw notFound("Session not found");
}
if (session.type !== "planning") {
throw badRequest("Only planning sessions support draft updates");
}
const rawTitle = typeof req.body?.title === "string" ? req.body.title : "";
const rawInitialPlan = typeof req.body?.initialPlan === "string" ? req.body.initialPlan : "";
const title = rawTitle.trim();
const initialPlan = rawInitialPlan.trim();
if (!title) {
throw badRequest("title is required");
}
if (!initialPlan) {
throw badRequest("initialPlan is required");
}
const updated = aiSessionStore.updateDraft(id, { title, initialPlan });
if (!updated) {
throw notFound("Session not found");
}
res.json({ ok: true });
});
/**
* DELETE /api/ai-sessions/:id
* Dismiss/cancel a background AI session.