From de9dd2c3efb0e0623b4a5992d9ab77d9f0d2cb9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morning=C2=B7Galaxy=20Dawn?= <3184876@qq.com> Date: Mon, 21 Sep 2026 09:51:11 +0800 Subject: [PATCH] feat(desktop): defer new task session creation until first message (#746) Clicking New Task now resets the composer to an unpersisted draft instead of eagerly writing an empty session row to SQLite and the sidebar. The session is materialized upon the first prompt or attachment. Abandoning the draft leaves no empty session row in history. --- apps/desktop/src/components/Sidebar.tsx | 7 ++-- .../src/stores/slices/session-slice.ts | 40 +++++++++---------- apps/desktop/test/app-store-sidebar.test.mjs | 7 ++-- .../desktop/test/composer-send-state.test.mjs | 16 ++------ apps/desktop/test/session-create.test.mjs | 7 ++-- ...0084-deferred-new-task-session-creation.md | 2 +- ...te-empty-session-slot-and-deduplication.md | 2 +- 7 files changed, 33 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 26542f2959..a3e6036996 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -619,10 +619,9 @@ export function Sidebar({ : sessions.filter( (session) => !sessionArchived(session, sessionMeta[session.id]), ); - // Empty sessions are durable sidebar rows now. Their message count, not - // their title, controls New Task reuse, so a manual rename never changes - // the empty-slot behavior. - return candidates; + // New-task drafts stay out of history until they carry input: sessions + // with only a default title (including legacy empty drafts) never render. + return candidates.filter((session) => !isDefaultSessionTitle(session.title)); }, [sessions, showArchived, sessionMeta]); const compareSessions = useCallback((a: SessionSummary, b: SessionSummary) => { diff --git a/apps/desktop/src/stores/slices/session-slice.ts b/apps/desktop/src/stores/slices/session-slice.ts index bb7c986b0a..5b6d4a1956 100644 --- a/apps/desktop/src/stores/slices/session-slice.ts +++ b/apps/desktop/src/stores/slices/session-slice.ts @@ -23,6 +23,7 @@ import { sessionNeedsModelPin, } from "../../lib/session-model"; import { + clearSessionPanes, retainSessionPane, } from "../../lib/session-panes"; import { @@ -476,28 +477,23 @@ export function createSessionSlice({ if (!runtime.navigationIntentIsCurrent(intent)) return; } - const latest = runtime.latestSessionInScope( - get().sessions, - requestedProjectPath, - get().sessionMeta, - ); - if ( - latest && - sessionIsReusableEmpty(latest, { - running: get().runningSessions[latest.id] === true, - liveMessageCount: runtime.liveMessageCountForSession(latest.id, get()), - submitted: runtime.submittedComposerDrafts.has(latest.id), - }) - ) { - if (get().activeSessionId === latest.id && get().page === "chat") return; - await get().selectSession(latest.id, { navigationIntent: intent }); - return; - } - - await persistSessionAndSelect({ - intent, - projectPath: requestedProjectPath, - draftConfiguration: null, + // New task starts as an unpersisted draft (ADR 0084): no session is created + // and no sidebar history row appears until the first message materializes it. + // The draft stays within its requested project scope, if any. + set((s) => { + const stack = s.navStack.slice(0, s.navIndex + 1); + const nextStack = [...stack, { page: "chat" as const }].slice(-50); + return { + ...switchWorkPanelSession(s, undefined), + ...clearSessionPanes(), + activeSessionId: undefined, + draftConfiguration: null, + messages: [], + page: "chat" as const, + navStack: nextStack, + navIndex: nextStack.length - 1, + isRunning: false, + }; }); })(); runtime.pendingNewSessionRequests.set(scopeKey, request); diff --git a/apps/desktop/test/app-store-sidebar.test.mjs b/apps/desktop/test/app-store-sidebar.test.mjs index 59771a1971..4db60532a4 100644 --- a/apps/desktop/test/app-store-sidebar.test.mjs +++ b/apps/desktop/test/app-store-sidebar.test.mjs @@ -66,13 +66,12 @@ test("project new-session creation uses one store-owned navigation transaction", ); }); -test("durable empty sessions render and title heuristics do not filter them", () => { - assert.doesNotMatch( +test("new-task drafts stay out of sidebar history until the first message", () => { + assert.match( sidebarSource, /candidates\.filter\(\(session\) => !isDefaultSessionTitle\(session\.title\)\)/, ); - assert.match(storeSource, /latestSessionInScope/); - assert.match(storeSource, /sessionIsReusableEmpty/); + assert.match(storeSource, /New task starts as an unpersisted draft/); assert.match(storeSource, /pendingNewSessionRequests/); assert.doesNotMatch(sidebarSource, /keptEmptyScopes/); }); diff --git a/apps/desktop/test/composer-send-state.test.mjs b/apps/desktop/test/composer-send-state.test.mjs index a4bfe505b0..74d2c965cf 100644 --- a/apps/desktop/test/composer-send-state.test.mjs +++ b/apps/desktop/test/composer-send-state.test.mjs @@ -189,26 +189,18 @@ test("editing a queued prompt needs an empty composer and restores its draft", ( assert.doesNotMatch(queueSlice, /text: item\.content/); }); -test("new task persists or reuses an empty session and keeps the run flag scoped", () => { +test("new task opens an unpersisted draft and keeps the run flag scoped", () => { const newSession = sessionSlice.match( /newSession: async [\s\S]*?\n forkSession: async/, )?.[0] ?? ""; assert.ok(newSession.length > 0, "newSession implementation not found"); - assert.match(newSession, /latestSessionInScope/); - assert.match(newSession, /sessionIsReusableEmpty/); - assert.match(newSession, /persistSessionAndSelect/); + assert.match(newSession, /New task starts as an unpersisted draft/); + assert.match(newSession, /activeSessionId: undefined/); assert.match(newSession, /pendingNewSessionRequests/); assert.doesNotMatch(newSession, /refreshSessions/); - // A newly selected empty session uses its own run state, so a turn still - // streaming in the previous session cannot leave it stuck on the stop - // button. assert.match( sessionCoordination, - /async function persistSessionAndSelect[\s\S]*?\n return sessionId;\n }\n/, - ); - assert.match( - sessionCoordination, - /isRunning: current\.runningSessions\[summary\.id\] \?\? false/, + /async function materializeDraftSession[\s\S]*?\n return persistSessionAndSelect\(\{ intent \}\);\n \}\n/, ); }); diff --git a/apps/desktop/test/session-create.test.mjs b/apps/desktop/test/session-create.test.mjs index a3a737c029..f127ef7ac4 100644 --- a/apps/desktop/test/session-create.test.mjs +++ b/apps/desktop/test/session-create.test.mjs @@ -35,14 +35,13 @@ test("empty sessions have a whole, already-known transcript window", () => { }); }); -test("new task reuses renderer-empty sessions without a blocking list refresh", () => { +test("new task opens unpersisted draft without a blocking list refresh", () => { const newSession = sessionSlice.match(/newSession: async [\s\S]*?\n forkSession: async/)?.[0] ?? ""; assert.ok(newSession.length > 0, "newSession implementation not found"); - assert.match(newSession, /sessionIsReusableEmpty/); - assert.match(newSession, /liveMessageCountForSession/); + assert.match(newSession, /New task starts as an unpersisted draft/); + assert.match(newSession, /activeSessionId: undefined/); assert.doesNotMatch(newSession, /refreshSessions/); - assert.match(newSession, /persistSessionAndSelect/); assert.match(newSession, /pendingNewSessionRequests/); }); diff --git a/docs/adr/0084-deferred-new-task-session-creation.md b/docs/adr/0084-deferred-new-task-session-creation.md index 094d0a34d6..3c8f095e22 100644 --- a/docs/adr/0084-deferred-new-task-session-creation.md +++ b/docs/adr/0084-deferred-new-task-session-creation.md @@ -2,7 +2,7 @@ - **Status:** Accepted - **Date:** 2026-08-14 -- **Related:** D220 · D088 · D093 · E2E-011b · E2E-011d +- **Related:** D220 · D088 · D093 · E2E-011b · E2E-011d · #746 ## Context diff --git a/docs/adr/0113-immediate-empty-session-slot-and-deduplication.md b/docs/adr/0113-immediate-empty-session-slot-and-deduplication.md index f7bce83160..e1be197dfc 100644 --- a/docs/adr/0113-immediate-empty-session-slot-and-deduplication.md +++ b/docs/adr/0113-immediate-empty-session-slot-and-deduplication.md @@ -1,6 +1,6 @@ # ADR 0113: Persist the New Task empty slot immediately and deduplicate it by message count -- **Status:** Accepted +- **Status:** Superseded by reinstatement of ADR 0084 (#746) - **Date:** 2026-08-21 - **Supersedes:** ADR 0084 / D220 - **Related:** D088 · D093 · D305 · E2E-011b · E2E-011d · E2E-011e · E2E-011g