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:
@@ -2610,15 +2610,17 @@ function PlanningSessionList({
|
||||
<span className="planning-sidebar-item-body">
|
||||
<span className="planning-sidebar-item-title">
|
||||
{/*
|
||||
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.
|
||||
Drafts hold a generic placeholder as their persisted
|
||||
title until the user blurs/closes (which fires
|
||||
summarizeTitle) or starts the session. While the title
|
||||
is still the placeholder, surface the inputPayload-
|
||||
derived preview so multiple drafts are distinguishable.
|
||||
Once a real title has been summarized, prefer it —
|
||||
otherwise the blur/close summarize would do model work
|
||||
that the user never sees in the sidebar.
|
||||
*/}
|
||||
{session.status === "draft" && session.preview
|
||||
? session.preview
|
||||
{session.status === "draft" && (!session.title || session.title === "New planning session")
|
||||
? (session.preview ?? "New planning session")
|
||||
: session.title || "Untitled session"}
|
||||
</span>
|
||||
<span className="planning-sidebar-item-meta">
|
||||
|
||||
@@ -201,7 +201,12 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
// Default mock for streaming
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "Test Plan" });
|
||||
// Server's createDraftSession always returns the placeholder title; the
|
||||
// real summarized title only arrives later via blur/close summarize or
|
||||
// when the session transitions out of draft. Mirror that in the mock so
|
||||
// the sidebar render rule (preview while title === placeholder) behaves
|
||||
// realistically in tests.
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
|
||||
@@ -2667,6 +2667,116 @@ describe("planning routes lock enforcement", () => {
|
||||
expect(clearedPayload.modelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips re-summarize on start when blur/close already summarized the same final text", async () => {
|
||||
// Sequence the bug guards:
|
||||
// 1. Create a draft.
|
||||
// 2. Blur → summarizeDraftTitle runs against the persisted text and
|
||||
// records `summarizedFor` so the start path knows the title is
|
||||
// up-to-date for that exact text.
|
||||
// 3. Click Start with the same text → startExistingSession should
|
||||
// skip its own summarize and leave the title from step 2 intact.
|
||||
const draft = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/planning/create-draft",
|
||||
JSON.stringify({ initialPlan: "Stable plan body the user already finished writing" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
const draftSessionId = draft.body.sessionId as string;
|
||||
|
||||
const blur = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/planning/${draftSessionId}/summarize-draft-title`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(blur.status).toBe(200);
|
||||
const titleAfterBlur = blur.body.title as string;
|
||||
expect(titleAfterBlur).not.toBe("New planning session");
|
||||
|
||||
setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES });
|
||||
const start = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/planning/start-streaming",
|
||||
JSON.stringify({
|
||||
initialPlan: "Stable plan body the user already finished writing",
|
||||
existingSessionId: draftSessionId,
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(start.status).toBe(201);
|
||||
|
||||
// Title is preserved exactly — no overwrite from a second summarize call.
|
||||
expect(aiSessionStore.get(draftSessionId)?.title).toBe(titleAfterBlur);
|
||||
|
||||
// And the persisted summarizedFor still equals the final initialPlan
|
||||
// so a future restart wouldn't re-summarize either.
|
||||
const payload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}");
|
||||
expect(payload.summarizedFor).toBe("Stable plan body the user already finished writing");
|
||||
});
|
||||
|
||||
it("re-summarizes on start when the user typed more after the last blur", async () => {
|
||||
// Counterpart to the dedup test: if the persisted text is now different
|
||||
// from what was last summarized, the start path must re-summarize so
|
||||
// the sidebar doesn't show a stale title once the session is running.
|
||||
const draft = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/planning/create-draft",
|
||||
JSON.stringify({ initialPlan: "Initial plan body before the late edits" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
const draftSessionId = draft.body.sessionId as string;
|
||||
|
||||
await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/planning/${draftSessionId}/summarize-draft-title`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
const blurredPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}");
|
||||
expect(blurredPayload.summarizedFor).toBe("Initial plan body before the late edits");
|
||||
|
||||
// User keeps typing — sync the new text via PATCH /draft. This must
|
||||
// preserve summarizedFor only if it still equals the new initialPlan;
|
||||
// since the text just changed, summarizedFor becomes stale.
|
||||
await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/ai-sessions/${draftSessionId}/draft`,
|
||||
JSON.stringify({ initialPlan: "Initial plan body before the late edits and now with extra detail" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
const updatedPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}");
|
||||
expect(updatedPayload.summarizedFor).toBeUndefined();
|
||||
|
||||
setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES });
|
||||
await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/planning/start-streaming",
|
||||
JSON.stringify({
|
||||
initialPlan: "Initial plan body before the late edits and now with extra detail",
|
||||
existingSessionId: draftSessionId,
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
// Start path summarized again (or fell back to truncation) against the
|
||||
// new text. summarizeTitle returns null for short text so the fallback
|
||||
// is the first 60 chars of the trimmed plan; the key assertion is that
|
||||
// the title now reflects the post-edit text, not the stale prefix it
|
||||
// had after the original blur.
|
||||
const finalTitle = aiSessionStore.get(draftSessionId)?.title ?? "";
|
||||
const expectedFallback =
|
||||
"Initial plan body before the late edits and now with extra detail".slice(0, 60).trim();
|
||||
expect(finalTitle).toBe(expectedFallback);
|
||||
expect(finalTitle).not.toBe("Initial plan body before the late edits");
|
||||
});
|
||||
|
||||
it("starts a draft that survived a backend restart by lazily rebuilding from SQLite", async () => {
|
||||
// Recreate the post-restart state: draft persisted in SQLite but the
|
||||
// in-memory sessions map is empty (rehydrateFromStore skips drafts since
|
||||
|
||||
@@ -230,6 +230,47 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace a draft session's title AND record the `initialPlan`
|
||||
* text the title was summarized from. Lets the start path skip a redundant
|
||||
* summarize when the persisted `summarizedFor` still matches the user's
|
||||
* final text. Existing inputPayload fields (initialPlan, model override)
|
||||
* are preserved by merge — this method only touches `summarizedFor`.
|
||||
*/
|
||||
markDraftSummarized(id: string, title: string, summarizedFor: string): boolean {
|
||||
const existing = this.get(id);
|
||||
if (!existing || existing.type !== "planning") return false;
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
if (existing.inputPayload) {
|
||||
try {
|
||||
const parsed = JSON.parse(existing.inputPayload);
|
||||
if (parsed && typeof parsed === "object") payload = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// Fall through with empty payload — better to lose stale fields than
|
||||
// to refuse the update and leave the title out of sync with reality.
|
||||
}
|
||||
}
|
||||
payload.summarizedFor = summarizedFor;
|
||||
const inputPayload = JSON.stringify(payload);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ai_sessions
|
||||
SET title = ?, inputPayload = ?, updatedAt = ?
|
||||
WHERE id = ? AND type = 'planning'`,
|
||||
)
|
||||
.run(title, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update persisted draft metadata for a planning session.
|
||||
* Persists the in-progress initialPlan so it survives reload; the sidebar
|
||||
@@ -249,9 +290,28 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
const now = new Date().toISOString();
|
||||
const trimmedPlan = draft.initialPlan.trim();
|
||||
const hasModelOverride = Boolean(draft.modelProvider && draft.modelId);
|
||||
|
||||
// Preserve the prior `summarizedFor` field so summarize results aren't
|
||||
// wiped on every draft sync. It only stays valid if it still matches the
|
||||
// new initialPlan; otherwise the previous summary is stale and the start
|
||||
// path will re-summarize against the current text.
|
||||
const existing = this.get(id);
|
||||
let preservedSummarizedFor: string | undefined;
|
||||
if (existing?.inputPayload) {
|
||||
try {
|
||||
const prev = JSON.parse(existing.inputPayload) as { summarizedFor?: unknown };
|
||||
if (typeof prev.summarizedFor === "string" && prev.summarizedFor === trimmedPlan) {
|
||||
preservedSummarizedFor = prev.summarizedFor;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed prior payloads — treat as no summary on file.
|
||||
}
|
||||
}
|
||||
|
||||
const inputPayload = JSON.stringify({
|
||||
initialPlan: trimmedPlan,
|
||||
...(hasModelOverride ? { modelProvider: draft.modelProvider, modelId: draft.modelId } : {}),
|
||||
...(preservedSummarizedFor ? { summarizedFor: preservedSummarizedFor } : {}),
|
||||
});
|
||||
const result = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user