fix(dashboard): show summarized draft title in sidebar and dedup start-path summarize

Closes the loop on the blur/close summarize work. Previously the sidebar
always rendered `session.preview` for drafts, so the model call fired by
blur/close updated `session.title` to a value the user never saw, and
then `startExistingSession` summarized again on Start Planning even when
blur had already produced a title for the exact same text.

The sidebar now shows the summarized title once available and falls
back to the inputPayload preview only while the title is still the
placeholder. summarizeDraftTitle persists the plan text it summarized
from via a new `markDraftSummarized` store method, and updateDraft
preserves that marker only while the persisted text still matches it
(text edits invalidate it automatically). startExistingSession reads
the marker and skips its own summarize when the persisted plan equals
what was summarized last, eliminating the redundant model call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 00:24:31 -07:00
parent 2affc143dc
commit 7243f04a70
6 changed files with 233 additions and 35 deletions

View File

@@ -215,11 +215,17 @@ export const DRAFT_PLACEHOLDER_TITLE = "New planning session";
* model override so reopening a draft restores the model selection the user
* picked at create time, and so summarizeDraftTitle calls hit that same
* model rather than silently falling back to project defaults.
*
* `summarizedFor` records the exact `initialPlan` string the current
* persisted title was summarized from. The start-existing path uses it to
* skip re-summarizing when blur/close already produced a title for the
* final text, avoiding a redundant model call.
*/
export interface DraftInputPayload {
initialPlan?: string;
modelProvider?: string;
modelId?: string;
summarizedFor?: string;
}
/** Session TTL in milliseconds (7 days) */
@@ -291,6 +297,8 @@ interface Session {
/** Model override the user picked at draft-create time. Persisted in inputPayload so reopen restores it. */
draftModelProvider?: string;
draftModelId?: string;
/** Plan text the current title was summarized from; lets startExistingSession skip a redundant re-summarize when blur/close already covered the final text. */
draftSummarizedFor?: string;
ntfyConfig?: PlanningNtfyConfig;
/** Last planning question notified via ntfy, keyed as `${sessionId}:${questionId}` for dedupe across reconnect/replay. */
lastNotifiedQuestionKey?: string;
@@ -405,6 +413,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
initialPlan: session.initialPlan,
...(session.draftModelProvider ? { modelProvider: session.draftModelProvider } : {}),
...(session.draftModelId ? { modelId: session.draftModelId } : {}),
...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}),
}),
conversationHistory: JSON.stringify(session.history),
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
@@ -461,6 +470,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
projectId: row.projectId ?? undefined,
draftModelProvider: payload.modelProvider,
draftModelId: payload.modelId,
draftSummarizedFor: payload.summarizedFor,
history: safeParseJson<PlanningHistoryEntry[]>(
row.conversationHistory,
[],
@@ -1007,9 +1017,12 @@ export async function summarizeDraftTitle(
return latest?.title ?? null;
}
_aiSessionStore.updateTitle(sessionId, finalTitle);
_aiSessionStore.markDraftSummarized(sessionId, finalTitle, trimmed);
const session = sessions.get(sessionId);
if (session) session.title = finalTitle;
if (session) {
session.title = finalTitle;
session.draftSummarizedFor = trimmed;
}
return finalTitle;
}
@@ -1047,12 +1060,14 @@ export async function startExistingSession(
}
// Drafts are sync'd via aiSessionStore.updateDraft, which only writes
// SQLite. Pull the latest initialPlan + persisted model override from the
// row so the agent receives everything the user typed, and so the title
// summary uses the model the draft was originally created under (rather
// than silently switching to whatever the project default happens to be).
// SQLite. Pull the latest initialPlan + persisted model override + the
// text the current title was already summarized from. Lets the agent
// see everything the user typed, lets summarize use the original model,
// and lets us skip a redundant model call when blur/close already
// summarized this exact text.
let persistedProvider: string | undefined;
let persistedModelId: string | undefined;
let persistedSummarizedFor: string | undefined;
let cameFromDraft = false;
if (_aiSessionStore) {
const row = _aiSessionStore.get(sessionId);
@@ -1064,31 +1079,37 @@ export async function startExistingSession(
}
persistedProvider = payload.modelProvider;
persistedModelId = payload.modelId;
persistedSummarizedFor = payload.summarizedFor;
}
}
// Always re-summarize when transitioning out of draft so the title reflects
// the FINAL text the user typed, even if a previous blur-fired summarize
// already replaced the placeholder against an older snapshot.
// Re-summarize when transitioning out of draft so the title reflects the
// FINAL text — but skip the model call when blur/close already produced a
// summary for this exact text and the user hasn't edited since. Saves
// tokens when the user blurs the textarea then immediately clicks Start.
if (cameFromDraft) {
const trimmed = session.initialPlan.trim();
const fallback = trimmed.slice(0, 60).trim();
if (session.title === DRAFT_PLACEHOLDER_TITLE) {
session.title = fallback || DRAFT_PLACEHOLDER_TITLE;
}
const summarizeProvider = modelProvider ?? persistedProvider;
const summarizeModelId = modelId ?? persistedModelId;
void (async () => {
try {
const generated = await summarizeTitle(trimmed, rootDir, summarizeProvider, summarizeModelId);
const finalTitle = generated?.trim() || fallback;
if (!finalTitle) return;
session.title = finalTitle;
_aiSessionStore?.updateTitle(sessionId, finalTitle);
} catch {
// Keep fallback title
const alreadySummarized =
session.title !== DRAFT_PLACEHOLDER_TITLE && persistedSummarizedFor === trimmed;
if (!alreadySummarized) {
const fallback = trimmed.slice(0, 60).trim();
if (session.title === DRAFT_PLACEHOLDER_TITLE) {
session.title = fallback || DRAFT_PLACEHOLDER_TITLE;
}
})();
const summarizeProvider = modelProvider ?? persistedProvider;
const summarizeModelId = modelId ?? persistedModelId;
void (async () => {
try {
const generated = await summarizeTitle(trimmed, rootDir, summarizeProvider, summarizeModelId);
const finalTitle = generated?.trim() || fallback;
if (!finalTitle) return;
session.title = finalTitle;
_aiSessionStore?.updateTitle(sessionId, finalTitle);
} catch {
// Keep fallback title
}
})();
}
}
persistSession(session, "generating");