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 ──────────────────────────────────────────────────────────
|
// ── Messages API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Response shape for GET /messages/inbox */
|
/** Response shape for GET /messages/inbox */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
fetchModels,
|
fetchModels,
|
||||||
cancelPlanning,
|
cancelPlanning,
|
||||||
stopPlanningGeneration,
|
stopPlanningGeneration,
|
||||||
|
updatePlanningSessionDraft,
|
||||||
updateGlobalSettings,
|
updateGlobalSettings,
|
||||||
type PlanningSession,
|
type PlanningSession,
|
||||||
type SubtaskItem,
|
type SubtaskItem,
|
||||||
@@ -165,6 +166,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// target is the overlay and would dismiss the modal mid-resize.
|
// target is the overlay and would dismiss the modal mid-resize.
|
||||||
const overlayMouseDownOnSelfRef = useRef(false);
|
const overlayMouseDownOnSelfRef = useRef(false);
|
||||||
const thinkingOutputRef = useRef<HTMLDivElement>(null);
|
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");
|
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
||||||
|
|
||||||
@@ -761,6 +764,85 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setMobileShowDetail(false);
|
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(
|
const handleDeleteSession = useCallback(
|
||||||
async (sessionId: string) => {
|
async (sessionId: string) => {
|
||||||
const isActiveServerSession = (status: AiSessionSummary["status"]) =>
|
const isActiveServerSession = (status: AiSessionSummary["status"]) =>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const mockRespondToPlanning = vi.fn();
|
|||||||
const mockRetryPlanningSession = vi.fn();
|
const mockRetryPlanningSession = vi.fn();
|
||||||
const mockCancelPlanning = vi.fn();
|
const mockCancelPlanning = vi.fn();
|
||||||
const mockStopPlanningGeneration = vi.fn();
|
const mockStopPlanningGeneration = vi.fn();
|
||||||
|
const mockUpdatePlanningSessionDraft = vi.fn();
|
||||||
const mockCreateTaskFromPlanning = vi.fn();
|
const mockCreateTaskFromPlanning = vi.fn();
|
||||||
const mockStartPlanningBreakdown = vi.fn();
|
const mockStartPlanningBreakdown = vi.fn();
|
||||||
const mockCreateTasksFromPlanning = vi.fn();
|
const mockCreateTasksFromPlanning = vi.fn();
|
||||||
@@ -45,6 +46,7 @@ vi.mock("../../api", () => ({
|
|||||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||||
|
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||||
@@ -221,6 +223,7 @@ describe("PlanningModeModal", () => {
|
|||||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||||
mockCancelPlanning.mockResolvedValue(undefined);
|
mockCancelPlanning.mockResolvedValue(undefined);
|
||||||
|
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
// Default: simulate receiving a question after a brief delay
|
// Default: simulate receiving a question after a brief delay
|
||||||
|
|||||||
@@ -469,6 +469,38 @@ describe("AiSessionStore", () => {
|
|||||||
expect(store.updateStatus("S-missing", "error", "Nope")).toBe(false);
|
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", () => {
|
it("listRecoverable returns awaiting_input and generating sessions", () => {
|
||||||
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
|
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
|
||||||
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });
|
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });
|
||||||
|
|||||||
@@ -219,6 +219,36 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
|||||||
return true;
|
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.
|
* Lightweight heartbeat for active sessions.
|
||||||
* Updates only `updatedAt` and intentionally does NOT emit
|
* Updates only `updatedAt` and intentionally does NOT emit
|
||||||
|
|||||||
@@ -3617,6 +3617,47 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
res.json({ ok: true });
|
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
|
* DELETE /api/ai-sessions/:id
|
||||||
* Dismiss/cancel a background AI session.
|
* Dismiss/cancel a background AI session.
|
||||||
|
|||||||
Reference in New Issue
Block a user