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:
@@ -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 */
|
||||
|
||||
@@ -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"]) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user