fix(dashboard): plumb planning draft text + model selection end-to-end

The planning modal's draft sync only updated SQLite, never the in-memory
session, so Start Planning ran the agent against whatever fragment was
typed before the first 500ms debounce and silently dropped everything
after. Drafts that survived a backend restart couldn't be started at
all (lazy rebuild was missing for the start path), reopened drafts left
the textarea empty, and the model override the user picked at create
time was forgotten on reopen — Start Planning would silently use the
project default instead.

This change rehydrates initialPlan from SQLite in startExistingSession,
flushes the latest text from the request body before reading it back,
lazy-rebuilds the in-memory session from the persisted row when needed,
and round-trips the model override through inputPayload so reopen
restores it into modal state. Sidebar drafts now render a derived
preview (from inputPayload, never persisted as title) so multiple
drafts are distinguishable while editing, and summarizeDraftTitle fires
on textarea blur and modal close — gated on status rather than title
content so blur-then-edit still refreshes rather than locking to the
first snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 00:05:28 -07:00
parent d73070c135
commit 2affc143dc
10 changed files with 785 additions and 60 deletions

View File

@@ -21,6 +21,7 @@ import {
cancelPlanning,
stopPlanningGeneration,
updatePlanningSessionDraft,
summarizePlanningDraftTitle,
updateGlobalSettings,
type PlanningSession,
type SubtaskItem,
@@ -167,7 +168,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
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);
const lastSyncedDraftRef = useRef<{
sessionId: string;
initialPlan: string;
modelProvider?: string;
modelId?: string;
} | null>(null);
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
@@ -602,7 +608,42 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
),
);
if (session.status === "awaiting_input" && session.currentQuestion) {
if (session.status === "draft") {
// Draft hasn't been started yet — restore the user's saved text +
// model selection into the editor, reattach the draft id so a
// future Start Planning call reuses this row, and route them back
// to the initial editor. Restoring the model ensures the start
// request uses the selection the user made when creating the
// draft, not whatever the modal's local state currently holds.
let savedPlan = "";
let savedProvider: string | undefined;
let savedModelId: string | undefined;
try {
const payload = session.inputPayload ? JSON.parse(session.inputPayload) : null;
if (payload && typeof payload.initialPlan === "string") {
savedPlan = payload.initialPlan;
}
if (payload && typeof payload.modelProvider === "string" && typeof payload.modelId === "string") {
savedProvider = payload.modelProvider;
savedModelId = payload.modelId;
}
} catch {
// Fall through with empty text; the row will remain editable.
}
setInitialPlan(savedPlan);
setPlanningModelProvider(savedProvider);
setPlanningModelId(savedModelId);
draftSessionIdRef.current = sessionId;
lastSyncedDraftRef.current = savedPlan
? {
sessionId,
initialPlan: savedPlan.trim(),
modelProvider: savedProvider,
modelId: savedModelId,
}
: null;
setView({ type: "initial" });
} else if (session.status === "awaiting_input" && session.currentQuestion) {
clearPlanningDescription(projectId);
const question = JSON.parse(session.currentQuestion);
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
@@ -771,9 +812,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return;
}
// Re-sync whenever the model selection changes, even if the plan text
// is unchanged — otherwise the persisted draft would silently keep the
// model the user picked at create time after they've switched.
const alreadySynced =
lastSyncedDraftRef.current?.sessionId === sessionId &&
lastSyncedDraftRef.current.initialPlan === trimmedPlan;
lastSyncedDraftRef.current.initialPlan === trimmedPlan &&
lastSyncedDraftRef.current.modelProvider === planningModelProvider &&
lastSyncedDraftRef.current.modelId === planningModelId;
if (alreadySynced) {
return;
}
@@ -782,17 +828,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
await updatePlanningSessionDraft(
sessionId,
{
title: trimmedPlan,
initialPlan: trimmedPlan,
modelProvider: planningModelProvider && planningModelId ? planningModelProvider : undefined,
modelId: planningModelProvider && planningModelId ? planningModelId : undefined,
},
projectId,
);
lastSyncedDraftRef.current = { sessionId, initialPlan: trimmedPlan };
lastSyncedDraftRef.current = {
sessionId,
initialPlan: trimmedPlan,
modelProvider: planningModelProvider,
modelId: planningModelId,
};
} catch {
// best-effort draft sync; avoid blocking typing UX on transient failures
}
},
[projectId],
[planningModelId, planningModelProvider, projectId],
);
useEffect(() => {
@@ -998,6 +1050,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen]);
// Flush any pending debounced draft sync, then ask the server to (re)derive
// the sidebar title from the persisted initialPlan. Fire-and-forget — the
// server is idempotent and a no-op once the session has started, so it's
// safe to invoke on textarea blur and on modal close. Awaiting the sync
// first guarantees the summary reflects the user's freshest text rather
// than whatever the last 500ms-debounced PATCH happened to persist.
const flushDraftAndSummarize = useCallback(
(sessionId: string, planText: string) => {
if (draftSyncTimerRef.current) {
clearTimeout(draftSyncTimerRef.current);
draftSyncTimerRef.current = null;
}
void (async () => {
try {
await syncPlanningDraft(sessionId, planText);
await summarizePlanningDraftTitle(sessionId, projectId);
} catch {
// best-effort title polish; don't surface to the user
}
})();
},
[projectId, syncPlanningDraft],
);
// Close the modal without abandoning the active server session. Sessions
// remain in the list and can be resumed later. Only an explicit Delete
// (from the sidebar) cancels and removes a session.
@@ -1007,6 +1083,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
savePlanningDescription(initialPlan, projectId);
}
// Capture before clearing — we want to fire summarize for this draft even
// though we're tearing down local state.
const draftSessionId = draftSessionIdRef.current;
if (draftSessionId && initialPlan.trim()) {
flushDraftAndSummarize(draftSessionId, initialPlan);
}
draftSessionIdRef.current = null;
if (draftDebounceRef.current) {
clearTimeout(draftDebounceRef.current);
@@ -1017,7 +1100,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsReconnecting(false);
setIsRetrying(false);
onClose();
}, [initialPlan, onClose, projectId, view.type]);
}, [flushDraftAndSummarize, initialPlan, onClose, projectId, view.type]);
// Handle escape key to close
useEffect(() => {
@@ -1422,6 +1505,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
type: "planning",
status: "draft",
title: response.title,
preview: content.length > 80 ? `${content.slice(0, 79).trimEnd()}` : content,
projectId: projectId ?? null,
lockedByTab: null,
updatedAt: new Date().toISOString(),
@@ -1444,6 +1528,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
handleStartPlanning();
}
}}
onBlur={() => {
// User paused or moved focus away — good moment to
// upgrade the sidebar from "New planning session" to a
// model-summarized title. No-op if the draft hasn't
// been persisted yet or if it's already been started.
const draftSessionId = draftSessionIdRef.current;
if (draftSessionId && initialPlan.trim()) {
flushDraftAndSummarize(draftSessionId, initialPlan);
}
}}
/>
</div>
@@ -2515,7 +2609,17 @@ function PlanningSessionList({
<PlanningSessionStatusIcon status={session.status} />
<span className="planning-sidebar-item-body">
<span className="planning-sidebar-item-title">
{session.title || "Untitled session"}
{/*
For draft rows the persisted title is intentionally a
generic placeholder so the sidebar doesn't leak raw
keystrokes. Surface the inputPayload-derived preview
instead so multiple drafts are distinguishable; once
the user starts the session, summarizeTitle replaces
`title` and `preview` is no longer present.
*/}
{session.status === "draft" && session.preview
? session.preview
: session.title || "Untitled session"}
</span>
<span className="planning-sidebar-item-meta">
<PlanningSessionStatusLabel status={session.status} />