diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 919342adbfc2..e51200298666 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1336,6 +1336,36 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)?.prompt).toBe("keep me around"); }); + it("resumes the most recent invested draft across projects and ignores empty ones", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + createdAt: "2026-01-01T00:00:00.000Z", + }); + store.setPrompt(draftId, "older work in progress"); + store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { + threadId: otherThreadId, + createdAt: "2026-01-02T00:00:00.000Z", + }); + store.setPrompt(otherDraftId, "newer work in progress"); + // A newer session with no typed content must never win over invested work. + store.setProjectDraftThreadId(remoteProjectRef, sharedDraftId, { + threadId: ThreadId.make("thread-empty"), + createdAt: "2026-01-03T00:00:00.000Z", + }); + + expect(useComposerDraftStore.getState().getMostRecentDraftSessionWithContent()?.draftId).toBe( + otherDraftId, + ); + }); + + it("returns no resumable draft when none has user content", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + + expect(useComposerDraftStore.getState().getMostRecentDraftSessionWithContent()).toBeNull(); + }); + it("clears every session for a project, including unmapped invested drafts", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8f4ad48e16ab..69b3fb58208e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -484,6 +484,12 @@ interface ComposerDraftStoreState { getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; + /** + * Most-recently-created, non-promoted draft session (across every project) + * that the user has actually invested content in. The index landing uses + * this to resume an in-progress draft instead of minting a blank one. + */ + getMostRecentDraftSessionWithContent: () => ProjectDraftSession | null; /** Reads mutable draft-session metadata by `DraftId`. */ getDraftSession: (draftId: DraftId) => DraftSessionState | null; /** Resolves a server-thread ref back to a matching draft session when one exists. */ @@ -2579,6 +2585,22 @@ const composerDraftStore = create()( } return null; }, + getMostRecentDraftSessionWithContent: () => { + const state = get(); + let best: { draftId: string; draftThread: DraftThreadState } | null = null; + for (const [draftId, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if (isDraftThreadPromoting(draftThread)) { + continue; + } + if (!composerDraftHasUserContent(state.draftsByThreadKey[draftId])) { + continue; + } + if (best === null || draftThread.createdAt > best.draftThread.createdAt) { + best = { draftId, draftThread }; + } + } + return best ? toProjectDraftSession(DraftId.make(best.draftId), best.draftThread) : null; + }, getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null, getDraftSessionByRef: (threadRef) => { for (const draftSession of Object.values(get().draftThreadsByThreadKey)) { diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index e6bc867e9820..9e7863e3e882 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,8 +1,8 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { createFileRoute, Link } from "@tanstack/react-router"; -import { LinkIcon, PlusIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; @@ -10,6 +10,7 @@ import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; +import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useAllEnvironmentShellsBootstrapped, @@ -42,6 +43,7 @@ function IndexDraftLanding() { const threads = useThreadShells(); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const handleNewThread = useNewThreadHandler(); + const navigate = useNavigate(); const startingRef = useRef(false); const [startState, setStartState] = useState({ failed: false, retryRequest: 0 }); @@ -54,7 +56,35 @@ function IndexDraftLanding() { ); useEffect(() => { - if (mostRecentProject === null || startingRef.current) { + if (!bootstrapped || startingRef.current) { + return; + } + // Coming back to "/" should resume an in-progress draft the user already + // started typing, rather than dropping them on a blank composer in + // whichever project happens to be most recently active. Explicit new-thread + // surfaces (the "+" buttons, hotkeys, palette) still mint fresh; this is the + // "return home" path, where losing unsent work is the real complaint. + const resumeDraft = useComposerDraftStore.getState().getMostRecentDraftSessionWithContent(); + const resumeProjectExists = + resumeDraft !== null && + projects.some( + (project) => + project.id === resumeDraft.projectId && + project.environmentId === resumeDraft.environmentId, + ); + if (resumeDraft && resumeProjectExists) { + startingRef.current = true; + void navigate({ + to: "/draft/$draftId", + params: { draftId: resumeDraft.draftId }, + replace: true, + }).catch(() => { + startingRef.current = false; + setStartState((state) => ({ ...state, failed: true })); + }); + return; + } + if (mostRecentProject === null) { return; } startingRef.current = true; @@ -64,7 +94,14 @@ function IndexDraftLanding() { startingRef.current = false; setStartState((state) => ({ ...state, failed: true })); }); - }, [handleNewThread, mostRecentProject, startState.retryRequest]); + }, [ + bootstrapped, + handleNewThread, + mostRecentProject, + navigate, + projects, + startState.retryRequest, + ]); if (!bootstrapped) { return null;