diff --git a/apps/app/src/components/drafts/LegacyDraftImport.tsx b/apps/app/src/components/drafts/LegacyDraftImport.tsx
new file mode 100644
index 00000000000..0eefd7a9115
--- /dev/null
+++ b/apps/app/src/components/drafts/LegacyDraftImport.tsx
@@ -0,0 +1,112 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import type { DraftContentInput, DraftOptions } from "@bb/server-contract";
+import { useRootComposeProjectId } from "@/lib/root-compose-selection";
+import {
+ usePromptBoxEnvironmentPreference,
+ usePromptBoxMachinePreference,
+ usePromptBoxModelPreference,
+ usePromptBoxPermissionModePreference,
+ usePromptBoxProviderPreference,
+ usePromptBoxReasoningLevelPreference,
+ usePromptBoxServiceTierPreference,
+} from "@/hooks/thread-creation-options/persisted-selection-fields";
+import { sanitizeStoredEnvironmentValue } from "@/hooks/useThreadCreationOptions";
+import { parseEnvironmentValue } from "@/components/pickers/environment-picker-value";
+import { appToast } from "@/components/ui/app-toast";
+import { importLegacyNewThreadDraft } from "@/lib/drafts/legacy-import";
+
+const IMPORT_TOAST_ID = "legacy-new-thread-draft-import";
+
+export function LegacyDraftImport() {
+ const [projectId] = useRootComposeProjectId();
+ const { value: providerId } = usePromptBoxProviderPreference();
+ const { value: model } = usePromptBoxModelPreference(providerId);
+ const { value: reasoningLevel } =
+ usePromptBoxReasoningLevelPreference(providerId);
+ const { value: serviceTier } = usePromptBoxServiceTierPreference();
+ const { value: permissionMode } = usePromptBoxPermissionModePreference();
+ const { value: environmentValue } =
+ usePromptBoxEnvironmentPreference(projectId);
+ const { value: machineId } = usePromptBoxMachinePreference(projectId);
+ const seed = useMemo((): Omit => {
+ const parsed = parseEnvironmentValue(
+ sanitizeStoredEnvironmentValue(environmentValue),
+ );
+ const environment: DraftOptions["environment"] =
+ parsed?.type === "provider"
+ ? {
+ type: "provider",
+ environmentProviderId: parsed.environmentProviderId,
+ machine:
+ machineId === "" ? null : { type: "existing", hostId: machineId },
+ inputs: null,
+ }
+ : parsed?.type === "reuse" && parsed.environmentId !== null
+ ? { type: "reuse", environmentId: parsed.environmentId }
+ : null;
+ return {
+ projectId,
+ options: {
+ providerId: providerId || null,
+ model: model || null,
+ reasoningLevel: reasoningLevel || null,
+ serviceTier: serviceTier || null,
+ permissionMode: permissionMode || null,
+ environment,
+ },
+ };
+ }, [
+ environmentValue,
+ machineId,
+ model,
+ permissionMode,
+ projectId,
+ providerId,
+ reasoningLevel,
+ serviceTier,
+ ]);
+ const seedRef = useRef(seed);
+ seedRef.current = seed;
+ const running = useRef(false);
+ const run = useCallback(async () => {
+ if (running.current) return;
+ running.current = true;
+ try {
+ let newerLegacyValue = true;
+ while (newerLegacyValue) {
+ const result = await importLegacyNewThreadDraft(seedRef.current);
+ if (result.error !== null) {
+ appToast.error("Could not save your existing draft", {
+ id: IMPORT_TOAST_ID,
+ description: "Your original draft is still on this device.",
+ duration: Infinity,
+ action: {
+ label: "Retry",
+ onClick: () => {
+ void run();
+ },
+ },
+ });
+ return;
+ }
+ newerLegacyValue = result.newerLegacyValue;
+ }
+ appToast.dismiss(IMPORT_TOAST_ID);
+ } finally {
+ running.current = false;
+ }
+ }, []);
+ useEffect(() => {
+ void run();
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === "bb.promptbox.contents-draft-3") void run();
+ };
+ window.addEventListener("storage", onStorage);
+ window.addEventListener("online", run);
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener("online", run);
+ };
+ }, [run]);
+ return null;
+}
diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
index c417d143ccc..f4b3b71faca 100644
--- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
@@ -6,6 +6,15 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppLayout } from "./AppLayout";
+vi.mock("@/lib/drafts/resource-runtime", () => ({
+ createNewThreadDraft: vi.fn(() => `drf_${crypto.randomUUID()}`),
+ initializeNewThreadDraft: vi.fn(),
+}));
+
+vi.mock("@/components/drafts/LegacyDraftImport", () => ({
+ LegacyDraftImport: () => null,
+}));
+
const ROOT_COMPOSE_PROJECT_ID_STORAGE_KEY = "bb.root-compose.project-id";
const mockUseThread = vi.hoisted(() => vi.fn());
diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index 49cb5417213..13ef14a5ee7 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -1,5 +1,13 @@
import { type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ lazy,
+ Suspense,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { flushSync } from "react-dom";
import { atom, useAtom, useAtomValue, useStore } from "jotai";
import { atomWithStorage } from "jotai/utils";
@@ -110,14 +118,25 @@ import {
shouldRestoreIOSViewportOnKeyboardDismissal,
useMobileVisualViewportHeight,
} from "./useMobileVisualViewportHeight";
+import { createNewThreadDraft } from "@/lib/drafts/resource-runtime";
+import { openDraftInSplit } from "@/lib/split-layout/openDraftInSplit";
import { wsManager } from "@/lib/ws";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import { findPaneByThread } from "@/lib/split-layout";
import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation";
import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory";
-import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
+import {
+ useRootComposeProjectId,
+ useSetRootComposeProjectId,
+} from "@/lib/root-compose-selection";
import { BackToAppCommandHandler } from "./BackToAppCommandHandler";
+const LegacyDraftImport = lazy(() =>
+ import("@/components/drafts/LegacyDraftImport").then((module) => ({
+ default: module.LegacyDraftImport,
+ })),
+);
+
const SIDEBAR_WIDTH_KEY = "bb.sidebar.width";
const SIDEBAR_OPEN_KEY = "bb.sidebar.open";
const SIDEBAR_MIN_WIDTH = 240;
@@ -419,6 +438,7 @@ export function AppLayout({ children }: AppLayoutProps) {
const { appRoutePath, settingsRoutePath, toolsBackRoutePath } =
useAppSettingsRouteMemory();
const setRootComposeProjectId = useSetRootComposeProjectId();
+ const [rootComposeProjectId] = useRootComposeProjectId();
useEffect(
() =>
wsManager.onThreadOpen((signal) => {
@@ -443,12 +463,33 @@ export function AppLayout({ children }: AppLayoutProps) {
}),
[isCompactViewport, navigate, store],
);
+ useEffect(
+ () =>
+ wsManager.onDraftOpen((signal) =>
+ openDraftInSplit({
+ store,
+ navigate,
+ draftId: signal.draftId,
+ split: signal.split,
+ isCompact: isCompactViewport,
+ }),
+ ),
+ [isCompactViewport, navigate, store],
+ );
useAppCommandHandler("thread.new", () => {
if (projectId !== undefined) {
setRootComposeProjectId(projectId);
}
- void navigate(getRootComposeRoutePath(), {
- state: { focusPrompt: true },
+ const draftId = createNewThreadDraft({
+ projectId: projectId ?? rootComposeProjectId,
+ });
+ openDraftInSplit({
+ store,
+ navigate: (route, options) =>
+ navigate(route, { ...options, state: { focusPrompt: true } }),
+ draftId,
+ split: "replace",
+ isCompact: isCompactViewport,
});
return true;
});
@@ -749,6 +790,9 @@ export function AppLayout({ children }: AppLayoutProps) {
return (
+
+
+
diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx
index fb2a1410930..558ff3733ff 100644
--- a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx
+++ b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx
@@ -55,9 +55,15 @@ import { maximizedPaneIdAtom, splitLayoutAtom } from "@/lib/split-layout/atoms";
import {
countPanes,
findPaneByContent,
+ listPanes,
type SplitLayout,
} from "@/lib/split-layout";
import { usePublishPluginDetailOpener } from "./plugin-detail-opener";
+vi.mock("@/lib/drafts/resource-runtime", async (importOriginal) => ({
+ ...(await importOriginal()),
+ createNewThreadDraft: vi.fn(() => `drf_${crypto.randomUUID()}`),
+}));
+
vi.mock("@/components/ui/app-toast", () => ({
appToast: {
dismiss: vi.fn(),
@@ -197,7 +203,7 @@ function renderSidebarItems(options: RenderSidebarItemsOptions = {}) {
root: {
type: "pane",
paneId: "pane-1",
- content: { kind: "new-thread" },
+ content: { kind: "new-thread", draftId: "drf_sidebar_test" },
},
focusedPaneId: "pane-1",
});
@@ -317,6 +323,7 @@ beforeEach(() => {
resetPluginFrontendBootStateForTest();
markPluginFrontendsSettled();
window.localStorage.clear();
+ window.sessionStorage.clear();
resetAllCrashedPluginSlotsForTest();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -330,6 +337,7 @@ afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.localStorage.clear();
+ window.sessionStorage.clear();
});
describe("PluginNavSidebarItems", () => {
@@ -668,7 +676,7 @@ describe("PluginNavSidebarItems", () => {
root: {
type: "split",
dir: "row",
- sizes: [1, 1, 1],
+ sizes: [1 / 3, 1 / 3, 1 / 3],
children: [
{
type: "pane",
@@ -1277,7 +1285,9 @@ describe("PluginNavSidebarItems", () => {
const layout = store.get(splitLayoutAtom)!;
expect(countPanes(layout.root)).toBe(2);
expect(
- findPaneByContent(layout.root, { kind: "new-thread" }),
+ listPanes(layout.root).find(
+ (pane) => pane.content.kind === "new-thread",
+ ),
).not.toBeNull();
expect(
findPaneByContent(layout.root, {
diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
index 484651681e1..57782218bfa 100644
--- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
+++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
@@ -130,7 +130,7 @@ export interface BuiltInSidebarNavEntry {
icon: ReactNode;
content: ReactNode;
disabled?: boolean;
- splitContent?: PaneContent;
+ splitContent?: PaneContent | (() => PaneContent);
onActivate: (event: SidebarNavActivationModifiers) => void;
}
@@ -617,14 +617,15 @@ function SidebarNavigationOverflowItem({
}) {
const splitActions = usePaneContentSplitActions();
const [isActionsOpen, setIsActionsOpen] = useState(false);
- const content: PaneContent | undefined = isPluginSidebarNavRow(row)
- ? {
- kind: "plugin-panel",
- pluginId: row.chrome.pluginId,
- panelPath: row.chrome.path,
- subPath: "",
- }
- : row.splitContent;
+ const content: PaneContent | (() => PaneContent) | undefined =
+ isPluginSidebarNavRow(row)
+ ? {
+ kind: "plugin-panel",
+ pluginId: row.chrome.pluginId,
+ panelPath: row.chrome.path,
+ subPath: "",
+ }
+ : row.splitContent;
const disabled = !isPluginSidebarNavRow(row) && row.disabled;
const canSplit =
splitEnabled &&
diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx
index 0c28f00320d..4d38c3330db 100644
--- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx
+++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx
@@ -27,9 +27,10 @@ import type {
NewThreadRequest,
PluginEnvironmentProviderInputsProps,
} from "@get-bb/plugin-sdk";
-import type {
- SystemEnvironmentProvider,
- SystemMachineProvider,
+import {
+ draftSchema,
+ type SystemEnvironmentProvider,
+ type SystemMachineProvider,
} from "@bb/server-contract";
import {
NewThreadComposer,
@@ -42,6 +43,12 @@ import {
import { encodeReuseValue } from "@/components/pickers/environment-picker-value";
import { useRootComposeReuseEnvironment } from "@/lib/root-compose-selection";
import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage";
+import { getDraftResourceStore } from "@/lib/drafts/resource-runtime";
+import {
+ draftResourceApi,
+ draftResourceQueryKey,
+} from "@/lib/drafts/resource-api";
+import { parseDraftRouteId } from "@/lib/draft-route";
import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures";
import { makeProjectWithThreadsResponse } from "@/test/fixtures/projects";
import { RootComposeView } from "@/views/RootComposeView";
@@ -76,9 +83,11 @@ const mocks = vi.hoisted(() => ({
sidebarNavigationSettled: true,
sidebarNavigationReplayed: false,
extraProjects: [] as Array>,
+ noProjects: false,
promptHistoryQueryOptions: [] as Array<{ enabled?: boolean } | undefined>,
environmentProviders: [] as unknown[],
closeTerminal: vi.fn(),
+ createProjectForSelection: vi.fn(),
plugins: [] as unknown[],
serverAccessReady: true,
machineProviders: [] as SystemMachineProvider[],
@@ -197,11 +206,13 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({
? {
data: {
sections: [],
- projects: [
- { ...PROJECT, threads: mocks.projectThreads },
- OTHER_PROJECT,
- ...mocks.extraProjects,
- ],
+ projects: mocks.noProjects
+ ? []
+ : [
+ { ...PROJECT, threads: mocks.projectThreads },
+ OTHER_PROJECT,
+ ...mocks.extraProjects,
+ ],
personalProject: makeProjectWithThreadsResponse({
id: "personal",
kind: "personal",
@@ -406,6 +417,7 @@ vi.mock("@/hooks/useQuickCreateProject", () => ({
isAvailable: false,
isCreating: false,
openCreateDialog: vi.fn(),
+ openCreateDialogForSelection: mocks.createProjectForSelection,
platform: null,
projectPathDialog: {
isOpen: false,
@@ -657,6 +669,7 @@ describe("PluginNewThreadComposer seeding", () => {
beforeEach(() => {
resetFixedPanelTabsStateForTest();
mocks.closeTerminal.mockClear();
+ mocks.createProjectForSelection.mockClear();
mocks.promptBoxProps.length = 0;
mocks.promptHistoryQueryOptions.length = 0;
mocks.copyAttachments.mockReset();
@@ -665,6 +678,7 @@ describe("PluginNewThreadComposer seeding", () => {
mocks.sidebarNavigationSettled = true;
mocks.sidebarNavigationReplayed = false;
mocks.extraProjects = [];
+ mocks.noProjects = false;
mocks.plugins = [];
mocks.serverAccessReady = true;
mocks.machineProviders = [];
@@ -1602,6 +1616,122 @@ describe("PluginNewThreadComposer seeding", () => {
},
);
+ it("keeps a reopened composer mounted while replacing its entire message", async () => {
+ mocks.noProjects = true;
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ const saved = draftSchema.parse({
+ id: "drf_replace_message",
+ revision: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ content: {
+ projectId: PERSONAL_PROJECT_ID,
+ prompt: { text: "Saved before reload" },
+ },
+ });
+ queryClient.setQueryData(draftResourceQueryKey(saved.id), saved);
+ vi.spyOn(draftResourceApi, "update").mockImplementation(
+ async (id, revision, content) => ({
+ ...saved,
+ id,
+ revision: revision + 1,
+ content,
+ }),
+ );
+ render(
+
+
+
+
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(latestPromptBoxProps().value).toBe("Saved before reload"),
+ );
+ act(() => latestPromptBoxProps().onChange("", []));
+ expect(screen.getByTestId("new-thread-prompt-box")).toBeTruthy();
+ expect(latestPromptBoxProps().value).toBe("");
+ act(() => latestPromptBoxProps().onChange("Replacement text", []));
+ await act(async () => {
+ await getDraftResourceStore(queryClient).flush(saved.id);
+ });
+ expect(
+ getDraftResourceStore(queryClient).getSnapshot(saved.id).content?.prompt
+ .text,
+ ).toBe("Replacement text");
+ });
+
+ it("keeps the same draft and attachments when its picker creates a project", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ const saved = draftSchema.parse({
+ id: "drf_created_project",
+ revision: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ content: {
+ projectId: "proj_1",
+ prompt: {
+ text: "Keep this message in the new project",
+ attachments: [
+ {
+ type: "localFile",
+ name: "notes.txt",
+ path: ".bb/attachments/notes.txt",
+ mimeType: "text/plain",
+ sizeBytes: 5,
+ },
+ ],
+ },
+ },
+ });
+ queryClient.setQueryData(draftResourceQueryKey(saved.id), saved);
+ vi.spyOn(draftResourceApi, "update").mockImplementation(
+ async (id, revision, content) => ({
+ ...saved,
+ id,
+ revision: revision + 1,
+ content,
+ }),
+ );
+ mocks.copyAttachments.mockResolvedValue(undefined);
+ const router = createMemoryRouter(
+ [{ path: "/", element: }],
+ { initialEntries: [`/?draft=${saved.id}`] },
+ );
+ render(
+
+
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(latestPromptBoxProps().value).toBe(saved.content.prompt.text),
+ );
+ act(() => latestPromptBoxProps().project.createProject.onCreate());
+ await act(async () => {
+ await mocks.createProjectForSelection.mock.calls[0][0]("proj_2");
+ });
+ await act(async () => {
+ await getDraftResourceStore(queryClient).flush(saved.id);
+ });
+ expect(parseDraftRouteId(router.state.location.search)).toBe(saved.id);
+ expect(mocks.copyAttachments).toHaveBeenCalledWith({
+ projectId: "proj_2",
+ sourceProjectId: "proj_1",
+ paths: [".bb/attachments/notes.txt"],
+ });
+ expect(
+ getDraftResourceStore(queryClient).getSnapshot(saved.id).content,
+ ).toMatchObject({ projectId: "proj_2", prompt: saved.content.prompt });
+ });
+
it("applies a replacing initial prompt from location state exactly once", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -1645,7 +1775,13 @@ describe("PluginNewThreadComposer seeding", () => {
await waitFor(() => {
expect(router.state.location.state).toBeNull();
});
- expect(rootDraft.getCurrent().text).toBe("Create a kanban plugin");
+ expect(rootDraft.getCurrent().text).toBe("leftover draft");
+ const draftId = parseDraftRouteId(router.state.location.search);
+ expect(draftId).not.toBeNull();
+ expect(
+ getDraftResourceStore(queryClient).getSnapshot(draftId!).content?.prompt
+ .text,
+ ).toBe("Create a kanban plugin");
const updateDepthErrors = consoleError.mock.calls.filter((call) =>
call.some(
(argument) =>
@@ -1871,6 +2007,7 @@ describe("NewThreadComposer environment providers", () => {
mocks.sidebarNavigationSettled = true;
mocks.sidebarNavigationReplayed = false;
mocks.extraProjects = [];
+ mocks.noProjects = false;
mocks.plugins = [];
mocks.serverAccessReady = true;
mocks.machineProviders = [];
diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx
index 231f5c0c5ad..f3f45dfc25f 100644
--- a/apps/app/src/components/plugin/PluginsOverview.test.tsx
+++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx
@@ -16,6 +16,9 @@ import {
useNavigate,
} from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { CREATE_PLUGIN_PROMPT } from "@bb/client-core";
+import { RouteNavigationProvider } from "@/components/ui/app-route-anchor";
+import { parseDraftRouteId } from "@/lib/draft-route";
import { focusManager } from "@tanstack/react-query";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { makeSystemConfig } from "@/test/fixtures/system-config";
@@ -23,6 +26,11 @@ import { SidebarHistoryNavigationControls } from "@/components/sidebar/SidebarHi
import { resetAppRouteHistoryForTest } from "@/lib/app-route-history";
import { PluginsOverview } from "./PluginsOverview";
+vi.mock("@/lib/drafts/resource-runtime", async (importOriginal) => ({
+ ...(await importOriginal()),
+ createNewThreadDraft: vi.fn(() => `drf_${crypto.randomUUID()}`),
+}));
+
vi.mock("@/components/plugin/PluginNewThreadComposer", () => ({
PluginNewThreadComposer: ({ initialPrompt }: { initialPrompt?: string }) => (
{initialPrompt}
@@ -186,13 +194,28 @@ function installFetch(plugins: readonly unknown[] = [AUTOMATIONS_PLUGIN]) {
}
function LocationPath() {
- return {useLocation().pathname};
+ const location = useLocation();
+ return (
+ <>
+
+ {location.pathname}
+
+
+ >
+ );
}
afterEach(() => {
focusManager.setFocused(undefined);
cleanup();
resetAppRouteHistoryForTest();
+ window.localStorage.clear();
+ window.sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -327,8 +350,10 @@ describe("PluginsOverview", () => {
render(
-
-
+
+
+
+
,
);
@@ -336,7 +361,17 @@ describe("PluginsOverview", () => {
expect(await screen.findByText("Automations")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "New plugin" }));
- expect(screen.getByTestId("location-path").textContent).toBe("/");
+ await waitFor(() =>
+ expect(screen.getByTestId("location-path").textContent).toBe("/"),
+ );
+ expect(screen.getByTestId("location-path").dataset.draftId).toBeTruthy();
+ expect(
+ JSON.parse(screen.getByTestId("location-state").textContent ?? "null"),
+ ).toEqual({
+ focusPrompt: true,
+ initialPrompt: CREATE_PLUGIN_PROMPT,
+ replaceInitialPrompt: false,
+ });
});
it("shows the Type filter on Installed instead of Category", async () => {
diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx
index 60caedae761..53154801fad 100644
--- a/apps/app/src/components/plugin/PluginsOverview.tsx
+++ b/apps/app/src/components/plugin/PluginsOverview.tsx
@@ -1,3 +1,4 @@
+import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft";
import { useMemo, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
@@ -31,10 +32,7 @@ import {
} from "@/components/plugin/plugin-provenance";
import { PLUGINS_INSTALLED_DESCRIPTION } from "@/components/plugin/plugins-collection-copy";
import { usePluginList } from "@/hooks/queries/plugin-settings-queries";
-import {
- getPluginDetailRoutePath,
- getRootComposeRoutePath,
-} from "@/lib/route-paths";
+import { getPluginDetailRoutePath } from "@/lib/route-paths";
export function PluginsOverview({
onOpenPlugin,
@@ -44,6 +42,7 @@ export function PluginsOverview({
onOpenPlugin?: (pluginId: string, trigger: HTMLButtonElement) => void;
} = {}) {
const navigate = useNavigate();
+ const openNewDraft = useOpenNewThreadDraft();
const [searchParams] = useSearchParams();
const listQuery = usePluginList({ enabled: true });
const plugins = useMemo(
@@ -130,13 +129,16 @@ export function PluginsOverview({
});
const startCreatePlugin = (prompt?: string) => {
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- initialPrompt: prompt ?? CREATE_PLUGIN_PROMPT,
- replaceInitialPrompt: prompt !== undefined,
+ openNewDraft(
+ {},
+ {
+ state: {
+ focusPrompt: true,
+ initialPrompt: prompt ?? CREATE_PLUGIN_PROMPT,
+ replaceInitialPrompt: prompt !== undefined,
+ },
},
- });
+ );
};
const installedActions = (
diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts
index 5339c3f4633..4e0a19c7d3c 100644
--- a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts
+++ b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts
@@ -284,3 +284,19 @@ describe("newThreadEnvironmentArgsToSeed round trip", () => {
).toBeNull();
});
});
+
+it("preserves an incomplete saved draft environment without choosing another machine", () => {
+ expect(
+ newThreadEnvironmentArgsToSeed({
+ type: "provider",
+ environmentProviderId: "branchy",
+ machine: null,
+ inputs: { branch: "draft-work" },
+ }),
+ ).toEqual({
+ selectionValue: "provider:branchy",
+ providerMachine: null,
+ providerHostId: null,
+ providerInputs: { branch: "draft-work" },
+ });
+});
diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.ts b/apps/app/src/components/plugin/new-thread-environment-seed.ts
index 70af3d13cf4..2ee7ea3c1da 100644
--- a/apps/app/src/components/plugin/new-thread-environment-seed.ts
+++ b/apps/app/src/components/plugin/new-thread-environment-seed.ts
@@ -6,6 +6,7 @@ import {
import type { EnvironmentMachineSelection, JsonValue } from "@bb/domain";
import type {
CreateThreadEnvironmentArgs,
+ DraftOptions,
ProviderEnvironmentArgs,
WorkspaceArgs,
} from "@bb/server-contract";
@@ -56,7 +57,9 @@ function workspaceAsProviderSugar(
}
export function newThreadEnvironmentArgsToSeed(
- environment: CreateThreadEnvironmentArgs,
+ environment:
+ | CreateThreadEnvironmentArgs
+ | NonNullable,
): NewThreadEnvironmentSeed | null {
if (environment.type === "project-default") {
return null;
diff --git a/apps/app/src/components/plugin/useSetPluginEnabled.ts b/apps/app/src/components/plugin/useSetPluginEnabled.ts
index 6ad38c7be9d..d0e38156bee 100644
--- a/apps/app/src/components/plugin/useSetPluginEnabled.ts
+++ b/apps/app/src/components/plugin/useSetPluginEnabled.ts
@@ -1,3 +1,4 @@
+import { createNewThreadDraft } from "@/lib/drafts/resource-runtime";
import { useCallback, useLayoutEffect, useRef } from "react";
import { flushSync } from "react-dom";
import { useLocation, useNavigate } from "react-router-dom";
@@ -40,7 +41,10 @@ export function useSetPluginEnabled() {
continue;
next =
listPanes(next.root).length === 1
- ? replacePaneContent(next, pane.paneId, { kind: "new-thread" })
+ ? replacePaneContent(next, pane.paneId, {
+ kind: "new-thread",
+ draftId: createNewThreadDraft({}),
+ })
: removePane(next, pane.paneId);
}
}
diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx
index 9f399b2e92b..9e352b3addc 100644
--- a/apps/app/src/components/promptbox/NewThreadComposer.tsx
+++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx
@@ -32,6 +32,7 @@ import type {
import type {
CreateThreadRequest,
CreateExecutionInputSources,
+ DraftOptions,
SidebarBootstrapResponse,
SystemEnvironmentProvider,
SystemExecutionOptionsModelLoadError,
@@ -80,8 +81,19 @@ import {
type PromptDraftScope,
} from "@/hooks/usePromptDraftStorage";
import { usePromptMentions } from "@/hooks/usePromptMentions";
-import { usePromptBoxMachinePreference } from "@/hooks/thread-creation-options/persisted-selection-fields";
-import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions";
+import {
+ usePromptBoxMachinePreference,
+ usePromptBoxProviderPreference,
+ usePromptBoxModelPreference,
+ usePromptBoxReasoningLevelPreference,
+ usePromptBoxPermissionModePreference,
+ usePromptBoxServiceTierPreference,
+ usePromptBoxEnvironmentPreference,
+} from "@/hooks/thread-creation-options/persisted-selection-fields";
+import {
+ sanitizeStoredEnvironmentValue,
+ useThreadCreationOptions,
+} from "@/hooks/useThreadCreationOptions";
import { useComposerTextEffects } from "@/lib/composer-text-effects";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history";
@@ -119,7 +131,9 @@ export interface NewThreadComposerSeed {
reasoningLevel?: ReasoningLevel;
serviceTier?: ServiceTier;
permissionMode?: PermissionMode;
- environment?: NewThreadRequest["environment"];
+ environment?:
+ | NewThreadRequest["environment"]
+ | NonNullable;
initialPrompt?: string;
}
@@ -181,6 +195,7 @@ export interface NewThreadComposerState {
}) => void;
setPermissionMode: (value: PermissionMode) => void;
setServiceTier: (value: ServiceTier | undefined) => void;
+ selectProject: (projectId: string | null) => Promise;
renderPromptBox: (options: NewThreadComposerPromptOptions) => ReactNode;
}
@@ -203,6 +218,19 @@ export interface NewThreadComposerProps {
projectId: string | null;
onProjectChange: (projectId: string) => void | Promise;
draftStorage: PromptDraftScope;
+ draftController?: PromptDraftController;
+ resourceBlockedReason?: string | null;
+ onOptionsChange?: (
+ options: Pick<
+ DraftOptions,
+ | "providerId"
+ | "model"
+ | "reasoningLevel"
+ | "serviceTier"
+ | "permissionMode"
+ | "environment"
+ >,
+ ) => void;
selectionScope: NewThreadComposerSelectionScope;
seed?: NewThreadComposerSeed;
resetKey?: string | number | null;
@@ -373,21 +401,6 @@ export function restorePromptDraftAfterOptionChange({
return changed ? restoredDraft : null;
}
-function useDraftPreservingOptionChange(
- current: T,
- setValue: (value: T) => void,
- snapshot: () => void,
-): (value: T) => void {
- return useCallback(
- (value: T) => {
- if (Object.is(current, value)) return;
- snapshot();
- setValue(value);
- },
- [current, setValue, snapshot],
- );
-}
-
function resolvePanelThreadId(
environmentId: string | null,
reuseThreadOptions: ReturnType,
@@ -399,10 +412,30 @@ function resolvePanelThreadId(
);
}
-export function NewThreadComposer({
+export function NewThreadComposer(props: NewThreadComposerProps) {
+ return props.draftController === undefined ? (
+
+ ) : (
+
+ );
+}
+
+function StoredNewThreadComposer(props: NewThreadComposerProps) {
+ const draftController = usePromptDraftStorage(props.draftStorage);
+ return (
+
+ );
+}
+
+function NewThreadComposerContent({
projectId: requestedProjectId,
onProjectChange,
- draftStorage,
+ draftController: promptDraft,
+ onOptionsChange,
+ resourceBlockedReason,
selectionScope,
seed,
resetKey,
@@ -410,7 +443,8 @@ export function NewThreadComposer({
onSubmit,
focusRequest,
children,
-}: NewThreadComposerProps) {
+}: NewThreadComposerProps & { draftController: PromptDraftController }) {
+ const isResourceDraft = onOptionsChange !== undefined;
const navigate = useNavigate();
const [localPromptBoxFocusRequest, setLocalPromptBoxFocusRequest] = useState<
number | null
@@ -436,9 +470,16 @@ export function NewThreadComposer({
(sidebarNavigationQuery.isSuccess && replayKnowsCandidate);
const projectId = useMemo(() => {
if (isProjectlessProjectId(requestedCandidate)) return PERSONAL_PROJECT_ID;
- if (!projects || !replayKnowsCandidate) return requestedCandidate;
+ if (isResourceDraft || !projects || !replayKnowsCandidate)
+ return requestedCandidate;
return candidateKnown ? requestedCandidate : PERSONAL_PROJECT_ID;
- }, [candidateKnown, projects, replayKnowsCandidate, requestedCandidate]);
+ }, [
+ candidateKnown,
+ isResourceDraft,
+ projects,
+ replayKnowsCandidate,
+ requestedCandidate,
+ ]);
const isProjectless = isProjectlessProjectId(projectId);
const currentProject = useMemo(() => {
if (isProjectless) {
@@ -619,10 +660,22 @@ export function NewThreadComposer({
? environmentSeed.providerMachine
: null;
const remembered: EnvironmentMachineSelection | null =
- selectionScope === "new-thread" && storedMachineId !== ""
+ (selectionScope === "new-thread" || isResourceDraft) &&
+ storedMachineId !== ""
? { type: "existing", hostId: storedMachineId }
: null;
const candidate = seeded ?? remembered;
+ if (
+ isResourceDraft &&
+ !seedOverridden &&
+ picked === null &&
+ environmentSeed?.selectionValue === effectiveValue
+ ) {
+ return { provider, machine: environmentSeed.providerMachine };
+ }
+ if (isResourceDraft && candidate !== null) {
+ return { provider, machine: candidate };
+ }
if (candidate?.type === "new") return { provider, machine: candidate };
if (usable(candidate?.hostId ?? null)) {
return { provider, machine: candidate };
@@ -637,6 +690,7 @@ export function NewThreadComposer({
},
[
seedOverridden,
+ isResourceDraft,
environmentSeed,
environmentProviders,
environmentProvidersByHostId,
@@ -650,16 +704,19 @@ export function NewThreadComposer({
const resolveProviderRouting = useCallback(
(environmentSelectionValue: string) => {
- const effectiveValue = resolveRootComposeEffectiveEnvironmentValue({
- environmentSelectionValue,
- environmentProviders,
- isProjectless,
- knownHostIds,
- primaryHostId,
- projectSources,
- reuseThreadOptions,
- reuseThreadOptionsLoading,
- });
+ const effectiveValue =
+ isResourceDraft && environmentSelectionValue !== ""
+ ? environmentSelectionValue
+ : resolveRootComposeEffectiveEnvironmentValue({
+ environmentSelectionValue,
+ environmentProviders,
+ isProjectless,
+ knownHostIds,
+ primaryHostId,
+ projectSources,
+ reuseThreadOptions,
+ reuseThreadOptionsLoading,
+ });
const providerSelection = resolveProviderSelection(effectiveValue);
if (providerSelection !== null) {
return providerSelection.machine?.type !== "existing"
@@ -672,6 +729,7 @@ export function NewThreadComposer({
: {};
},
[
+ isResourceDraft,
environmentProviders,
isProjectless,
knownHostIds,
@@ -708,21 +766,52 @@ export function NewThreadComposer({
seed?.model === undefined ||
seed?.reasoningLevel === undefined ||
seed?.permissionMode === undefined);
+ const { value: preferredProviderId, setValue: setPreferredProviderId } =
+ usePromptBoxProviderPreference();
+ const initialProviderId =
+ seed?.providerId ??
+ (isResourceDraft ? preferredProviderId || undefined : undefined) ??
+ projectDefaults?.providerId;
+ const { value: preferredModel, setValue: setPreferredModel } =
+ usePromptBoxModelPreference(initialProviderId ?? "");
+ const { value: preferredReasoning, setValue: setPreferredReasoning } =
+ usePromptBoxReasoningLevelPreference(initialProviderId ?? "");
+ const { value: preferredPermission, setValue: setPreferredPermission } =
+ usePromptBoxPermissionModePreference();
+ const { value: preferredServiceTier, setValue: setPreferredServiceTier } =
+ usePromptBoxServiceTierPreference();
+ const { value: preferredEnvironment, setValue: setPreferredEnvironment } =
+ usePromptBoxEnvironmentPreference(projectId);
const creationOptions = useThreadCreationOptions({
scope: selectionScope,
+ preserveUnavailableSelections: isResourceDraft,
preferenceProjectId: projectId,
resetKey: `${projectId}\0${seedSignature}`,
resolveProviderRouting,
- initialProviderId: seed?.providerId ?? projectDefaults?.providerId,
+ initialProviderId,
preferReadyProviderWhenUnset:
preferReadyProviderWhenUnset && projectDefaults === null,
- initialModel: seed?.model ?? projectDefaults?.model,
- initialServiceTier: seed?.serviceTier ?? projectDefaults?.serviceTier,
+ initialModel:
+ seed?.model ??
+ (isResourceDraft ? preferredModel || undefined : undefined) ??
+ projectDefaults?.model,
+ initialServiceTier:
+ seed?.serviceTier ??
+ (isResourceDraft ? preferredServiceTier || undefined : undefined) ??
+ projectDefaults?.serviceTier,
initialReasoningLevel:
- seed?.reasoningLevel ?? projectDefaults?.reasoningLevel,
+ seed?.reasoningLevel ??
+ (isResourceDraft ? preferredReasoning || undefined : undefined) ??
+ projectDefaults?.reasoningLevel,
initialPermissionMode:
- seed?.permissionMode ?? projectDefaults?.permissionMode,
- initialEnvironmentSelectionValue: environmentSeed?.selectionValue,
+ seed?.permissionMode ??
+ (isResourceDraft ? preferredPermission || undefined : undefined) ??
+ projectDefaults?.permissionMode,
+ initialEnvironmentSelectionValue:
+ environmentSeed?.selectionValue ??
+ (isResourceDraft
+ ? sanitizeStoredEnvironmentValue(preferredEnvironment)
+ : undefined),
});
const {
activeModel,
@@ -760,7 +849,6 @@ export function NewThreadComposer({
} = creationOptions;
const selectedThreadModel = activeModel?.model ?? selectedModel;
- const promptDraft = usePromptDraftStorage(draftStorage);
const textEffects = useComposerTextEffects(promptDraft.storageKey);
const promptOptionDraftSnapshotRef = useRef(null);
const snapshotDraftBeforeOptionChange = useCallback(() => {
@@ -808,7 +896,7 @@ export function NewThreadComposer({
: { selectionValue: value, machine: providerMachine },
);
if (
- selectionScope === "new-thread" &&
+ (selectionScope === "new-thread" || isResourceDraft) &&
parseEnvironmentValue(value)?.type === "provider"
) {
setStoredMachineId(
@@ -816,29 +904,35 @@ export function NewThreadComposer({
);
}
setCreationEnvironmentSelectionValue(value);
+ if (isResourceDraft) setPreferredEnvironment(value);
},
[
environmentSelectionValue,
pickedProviderMachine,
setCreationEnvironmentSelectionValue,
selectionScope,
+ isResourceDraft,
+ setPreferredEnvironment,
setStoredMachineId,
snapshotDraftBeforeOptionChange,
],
);
const effectiveEnvironmentValue = useMemo(
() =>
- resolveRootComposeEffectiveEnvironmentValue({
- environmentSelectionValue,
- environmentProviders,
- isProjectless,
- knownHostIds,
- primaryHostId,
- projectSources,
- reuseThreadOptions,
- reuseThreadOptionsLoading,
- }),
+ isResourceDraft && environmentSelectionValue !== ""
+ ? environmentSelectionValue
+ : resolveRootComposeEffectiveEnvironmentValue({
+ environmentSelectionValue,
+ environmentProviders,
+ isProjectless,
+ knownHostIds,
+ primaryHostId,
+ projectSources,
+ reuseThreadOptions,
+ reuseThreadOptionsLoading,
+ }),
[
+ isResourceDraft,
environmentSelectionValue,
environmentProviders,
isProjectless,
@@ -1129,11 +1223,28 @@ export function NewThreadComposer({
const machineProviderInputs = useMachineProviderInputs({
provider: compositionMachineProvider,
initialValue: seededMachineInputs,
- instanceId: `new-thread-${projectId}`,
+ instanceId: `new-thread-${isResourceDraft ? promptDraft.storageKey : projectId}`,
});
const compositionMachineProviderId = compositionMachineProvider?.id ?? null;
const compositionMachineInputsSchema =
compositionMachineProvider?.inputs ?? null;
+ const resolvedProviderMachine = useMemo(
+ (): EnvironmentMachineSelection | null =>
+ compositionMachineProviderId !== null &&
+ compositionMachineInputsSchema !== null
+ ? {
+ type: "new",
+ machineProviderId: compositionMachineProviderId,
+ inputs: machineProviderInputs.value,
+ }
+ : providerMachine,
+ [
+ compositionMachineInputsSchema,
+ compositionMachineProviderId,
+ machineProviderInputs.value,
+ providerMachine,
+ ],
+ );
const selectedEnvironment = useMemo(
() =>
@@ -1141,15 +1252,7 @@ export function NewThreadComposer({
environmentValue: effectiveEnvironmentValue,
projectId,
environmentProviders,
- providerMachine:
- compositionMachineProviderId !== null &&
- compositionMachineInputsSchema !== null
- ? {
- type: "new",
- machineProviderId: compositionMachineProviderId,
- inputs: machineProviderInputs.value,
- }
- : providerMachine,
+ providerMachine: resolvedProviderMachine,
providerInputs: submissionProviderInputs,
}),
[
@@ -1157,13 +1260,65 @@ export function NewThreadComposer({
environmentProviders,
projectId,
submissionProviderInputs,
- compositionMachineInputsSchema,
- compositionMachineProviderId,
- machineProviderInputs.value,
- providerMachine,
+ resolvedProviderMachine,
],
);
+ const draftEnvironment = useMemo((): DraftOptions["environment"] => {
+ const matchingSeed =
+ !seedOverridden &&
+ environmentSeed?.selectionValue === effectiveEnvironmentValue
+ ? environmentSeed
+ : null;
+ if (parsedEnvironment?.type === "provider") {
+ return {
+ type: "provider",
+ environmentProviderId: parsedEnvironment.environmentProviderId,
+ machine:
+ resolvedProviderMachine ?? matchingSeed?.providerMachine ?? null,
+ inputs:
+ submissionProviderInputs ?? matchingSeed?.providerInputs ?? null,
+ };
+ }
+ if (
+ parsedEnvironment?.type === "reuse" &&
+ parsedEnvironment.environmentId !== null
+ ) {
+ return { type: "reuse", environmentId: parsedEnvironment.environmentId };
+ }
+ const fallback = selectedEnvironment ?? seed?.environment ?? null;
+ return fallback?.type === "provider"
+ ? { ...fallback, machine: fallback.machine ?? null }
+ : fallback;
+ }, [
+ effectiveEnvironmentValue,
+ seedOverridden,
+ environmentSeed,
+ parsedEnvironment,
+ resolvedProviderMachine,
+ seed?.environment,
+ selectedEnvironment,
+ submissionProviderInputs,
+ ]);
+ useEffect(() => {
+ onOptionsChange?.({
+ providerId: selectedProviderId || null,
+ model: selectedThreadModel || null,
+ reasoningLevel,
+ serviceTier: serviceTier ?? null,
+ permissionMode,
+ environment: draftEnvironment,
+ });
+ }, [
+ draftEnvironment,
+ onOptionsChange,
+ permissionMode,
+ reasoningLevel,
+ selectedProviderId,
+ selectedThreadModel,
+ serviceTier,
+ ]);
+
const initialPromptDraft = useInitialPromptDraft(seed?.initialPrompt ?? null);
const seedInitialPrompt = promptDraft.restoreIfEmpty;
const focusPromptBox = useCallback(() => {
@@ -1225,7 +1380,8 @@ export function NewThreadComposer({
async (nextProjectId: string | null) => {
const nextValue = nextProjectId ?? PERSONAL_PROJECT_ID;
if (
- nextValue === projectId ||
+ (nextValue === projectId &&
+ !(isResourceDraft && requestedProjectId === null)) ||
isCopyingAttachmentsRef.current ||
isUploadingRef.current ||
isSubmittingRef.current
@@ -1264,7 +1420,14 @@ export function NewThreadComposer({
setIsCopyingAttachments(false);
}
},
- [onProjectChange, projectId, promptDraft, snapshotDraftBeforeOptionChange],
+ [
+ isResourceDraft,
+ onProjectChange,
+ projectId,
+ promptDraft,
+ requestedProjectId,
+ snapshotDraftBeforeOptionChange,
+ ],
);
const reuseEnvironmentId =
@@ -1413,29 +1576,77 @@ export function NewThreadComposer({
supportsServiceTier,
],
);
+ const seedSubmissionEnvironment =
+ seed?.environment?.type === "provider"
+ ? { ...seed.environment, machine: seed.environment.machine ?? undefined }
+ : (seed?.environment ?? null);
const submissionEnvironment = selectedProviderMachineUnavailable
? null
: (selectedEnvironment ??
- (selectionScope === "new-thread" ? seed?.environment : undefined) ??
- null);
- const submitDisabledReason = resolveNewThreadSubmitDisabledReason({
- environmentProviderInputsBlocker:
- machineProviderInputs.blockedReason ?? environmentProviderInputsBlocker,
- environmentSetupRequiredReason:
- environmentSetupRequiredReason ?? machineServerAccessReason,
- isCopyingAttachments,
- isLoadingModels,
- isSubmitting,
- isUploading,
- modelLoadError,
- projectDefaultsStatus: projectDefaultsState.status,
- projectDefaultsUnavailable,
- promptInputEmpty: promptInput.length === 0,
- providerDisplayName: selectedProviderDisplayName,
- selectedProviderId,
- selectedThreadModel,
- submissionEnvironmentUnavailable: submissionEnvironment === null,
- });
+ (selectionScope === "new-thread" && !isResourceDraft
+ ? seedSubmissionEnvironment
+ : null));
+ const unavailableDraftChoice = !isResourceDraft
+ ? null
+ : requestedProjectId === null
+ ? "Choose a project for this draft."
+ : sidebarNavigationSettled && !candidateKnown
+ ? "This draft's project is unavailable. Choose another project."
+ : selectedProviderId !== "" &&
+ providerOptions.length > 0 &&
+ !providerOptions.some(
+ (option) => option.value === selectedProviderId,
+ )
+ ? "This draft's provider is unavailable. Choose another provider."
+ : creationOptions.modelCatalogIsVerified &&
+ selectedThreadModel !== "" &&
+ activeModel === undefined
+ ? "This draft's model is unavailable. Choose another model."
+ : creationOptions.permissionModeIsVerified &&
+ !permissionModeOptions.some(
+ (option) =>
+ option.value === permissionMode && !option.disabled,
+ )
+ ? "This draft's permission mode is unavailable on this machine."
+ : reasoningOptions.length > 0 &&
+ !reasoningOptions.some(
+ (option) => option.value === reasoningLevel,
+ )
+ ? "This draft's reasoning level is unavailable for this model."
+ : providerMachine?.type === "existing" &&
+ !knownHostIds.has(providerMachine.hostId)
+ ? "This draft's machine is unavailable. Choose another machine."
+ : parsedEnvironment?.type === "reuse" &&
+ parsedEnvironment.environmentId !== null &&
+ !reuseThreadOptionsLoading &&
+ !reuseThreadOptions.some(
+ (option) =>
+ option.environmentId ===
+ parsedEnvironment.environmentId,
+ )
+ ? "This draft's workspace is unavailable. Choose another workspace."
+ : null;
+ const submitDisabledReason =
+ resourceBlockedReason ??
+ unavailableDraftChoice ??
+ resolveNewThreadSubmitDisabledReason({
+ environmentProviderInputsBlocker:
+ machineProviderInputs.blockedReason ?? environmentProviderInputsBlocker,
+ environmentSetupRequiredReason:
+ environmentSetupRequiredReason ?? machineServerAccessReason,
+ isCopyingAttachments,
+ isLoadingModels,
+ isSubmitting,
+ isUploading,
+ modelLoadError,
+ projectDefaultsStatus: projectDefaultsState.status,
+ projectDefaultsUnavailable,
+ promptInputEmpty: promptInput.length === 0,
+ providerDisplayName: selectedProviderDisplayName,
+ selectedProviderId,
+ selectedThreadModel,
+ submissionEnvironmentUnavailable: submissionEnvironment === null,
+ });
const submitDraft = useCallback(
async (
blockedReason: string | null,
@@ -1488,10 +1699,10 @@ export function NewThreadComposer({
setIsSubmitting(true);
setAttachmentError(null);
const clearedSubmittedDraft =
- promptDraft.clearIfCurrentMatches(submittedDraft);
+ !isResourceDraft && promptDraft.clearIfCurrentMatches(submittedDraft);
try {
await onSubmit(request);
- clearReuseEnvironment();
+ if (!isResourceDraft) clearReuseEnvironment();
} catch (submitError) {
if (clearedSubmittedDraft) {
promptDraft.restoreIfEmpty(submittedDraft);
@@ -1504,6 +1715,7 @@ export function NewThreadComposer({
},
[
clearReuseEnvironment,
+ isResourceDraft,
executionInputSources,
onSubmit,
permissionMode,
@@ -1538,30 +1750,80 @@ export function NewThreadComposer({
};
}, [submitDraft]);
- const handleProviderChange = useDraftPreservingOptionChange(
- selectedProviderId,
- setSelectedProviderId,
- snapshotDraftBeforeOptionChange,
+ const handleProviderChange = useCallback(
+ (value: string) => {
+ if (Object.is(selectedProviderId, value)) return;
+ snapshotDraftBeforeOptionChange();
+ if (isResourceDraft) setPreferredProviderId(value);
+ setSelectedProviderId(value);
+ },
+ [
+ isResourceDraft,
+ selectedProviderId,
+ setPreferredProviderId,
+ setSelectedProviderId,
+ snapshotDraftBeforeOptionChange,
+ ],
);
- const handleModelChange = useDraftPreservingOptionChange(
- selectedModel,
- setSelectedModel,
- snapshotDraftBeforeOptionChange,
+ const handleModelChange = useCallback(
+ (value: string) => {
+ if (Object.is(selectedModel, value)) return;
+ snapshotDraftBeforeOptionChange();
+ if (isResourceDraft) setPreferredModel(value);
+ setSelectedModel(value);
+ },
+ [
+ isResourceDraft,
+ selectedModel,
+ setPreferredModel,
+ setSelectedModel,
+ snapshotDraftBeforeOptionChange,
+ ],
);
- const handleReasoningChange = useDraftPreservingOptionChange(
- reasoningLevel,
- setReasoningLevel,
- snapshotDraftBeforeOptionChange,
+ const handleReasoningChange = useCallback(
+ (value: ReasoningLevel) => {
+ if (Object.is(reasoningLevel, value)) return;
+ snapshotDraftBeforeOptionChange();
+ if (isResourceDraft) setPreferredReasoning(value);
+ setReasoningLevel(value);
+ },
+ [
+ isResourceDraft,
+ reasoningLevel,
+ setPreferredReasoning,
+ setReasoningLevel,
+ snapshotDraftBeforeOptionChange,
+ ],
);
- const handlePermissionChange = useDraftPreservingOptionChange(
- permissionMode,
- setPermissionMode,
- snapshotDraftBeforeOptionChange,
+ const handlePermissionChange = useCallback(
+ (value: PermissionMode) => {
+ if (Object.is(permissionMode, value)) return;
+ snapshotDraftBeforeOptionChange();
+ if (isResourceDraft) setPreferredPermission(value);
+ setPermissionMode(value);
+ },
+ [
+ isResourceDraft,
+ permissionMode,
+ setPreferredPermission,
+ setPermissionMode,
+ snapshotDraftBeforeOptionChange,
+ ],
);
- const handleServiceTierChange = useDraftPreservingOptionChange(
- serviceTier,
- setServiceTier,
- snapshotDraftBeforeOptionChange,
+ const handleServiceTierChange = useCallback(
+ (value: ServiceTier | undefined) => {
+ if (Object.is(serviceTier, value)) return;
+ snapshotDraftBeforeOptionChange();
+ if (isResourceDraft) setPreferredServiceTier(value ?? "");
+ setServiceTier(value);
+ },
+ [
+ isResourceDraft,
+ serviceTier,
+ setPreferredServiceTier,
+ setServiceTier,
+ snapshotDraftBeforeOptionChange,
+ ],
);
const handleWorktreeChange = useCallback(
(environmentId: string) => {
@@ -1702,7 +1964,12 @@ export function NewThreadComposer({
}}
project={{
projects: projectOptions,
- value: options.allowNoProject && isProjectless ? null : projectId,
+ value:
+ isResourceDraft && requestedProjectId === null
+ ? null
+ : options.allowNoProject && isProjectless
+ ? null
+ : projectId,
onChange: handleProjectChange,
allowNoProject: options.allowNoProject,
createProject: options.createProject,
@@ -1774,6 +2041,8 @@ export function NewThreadComposer({
isCopyingAttachments,
isLoadingModels,
isProjectless,
+ isResourceDraft,
+ requestedProjectId,
isSubmitting,
isUploading,
modelLoadError,
@@ -1844,6 +2113,7 @@ export function NewThreadComposer({
setProviderModelReasoning,
setPermissionMode,
setServiceTier,
+ selectProject: handleProjectChange,
renderPromptBox,
}}
/>
diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx
index 8c2372246f0..c33db3f26d6 100644
--- a/apps/app/src/components/sidebar/AppSidebar.tsx
+++ b/apps/app/src/components/sidebar/AppSidebar.tsx
@@ -24,7 +24,10 @@ import { SidebarUpdatesBadge } from "./SidebarUpdatesBadge";
import { SidebarResizeHandle, SidebarTopReserveRow } from "./SidebarChrome";
import { SIDEBAR_FOOTER_ACTION_CLASS } from "./sidebarRowClasses";
import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject";
-import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths";
+import { getThreadRoutePath } from "@/lib/route-paths";
+import { getDraftRoutePath } from "@/lib/draft-route";
+import { createNewThreadDraft } from "@/lib/drafts/resource-runtime";
+import { useRootComposeProjectId } from "@/lib/root-compose-selection";
import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag";
import { openUrlInExternalBrowser } from "@/lib/url-open-routing";
import {
@@ -45,8 +48,6 @@ import {
import { useRouteState } from "@/hooks/useRouteState";
import { SidebarNavigationRegion } from "./SidebarNavigationRegion";
-const NEW_THREAD_PANE_CONTENT = { kind: "new-thread" } as const;
-
const BUG_REPORT_NEW_ISSUE_URL = "https://github.com/get-bb/bb/issues/new";
interface AppSidebarProps {
@@ -66,12 +67,21 @@ export function AppSidebar({
const threadListReplacement = useThreadListReplacement();
const { threadId: activeThreadId } = useRouteState();
const navigate = useNavigate();
+ const [projectId] = useRootComposeProjectId();
+ const closeOnMobile = useCloseMobileSidebar();
+ const createNewThreadContent = useCallback(
+ () => ({
+ kind: "new-thread" as const,
+ draftId: createNewThreadDraft({ projectId }),
+ }),
+ [projectId],
+ );
const newThreadSplit = usePaneContentSplitDrag({
- content: NEW_THREAD_PANE_CONTENT,
+ content: createNewThreadContent,
enabled: true,
label: "New thread",
+ onNavigate: closeOnMobile,
});
- const closeOnMobile = useCloseMobileSidebar();
const { isCompactViewport, openMobile } = useSidebar();
const [compactCustomizeMode, setCompactCustomizeMode] = useState(false);
const [threadShortcutKeysById, setThreadShortcutKeysById] = useState<
@@ -90,10 +100,10 @@ export function AppSidebar({
const handleNewChat = useCallback(() => {
closeOnMobile();
- void navigate(getRootComposeRoutePath(), {
+ void navigate(getDraftRoutePath(createNewThreadContent().draftId), {
state: { focusPrompt: true },
});
- }, [closeOnMobile, navigate]);
+ }, [closeOnMobile, createNewThreadContent, navigate]);
const showThreadShortcuts = useCallback(() => {
const targets = getSidebarThreadShortcutTargets(sidebarRef.current);
diff --git a/apps/app/src/components/sidebar/ArchivedRows.tsx b/apps/app/src/components/sidebar/ArchivedRows.tsx
new file mode 100644
index 00000000000..cccebcd88c7
--- /dev/null
+++ b/apps/app/src/components/sidebar/ArchivedRows.tsx
@@ -0,0 +1,75 @@
+import { useMemo } from "react";
+import { Button } from "@bb/shared-ui/button";
+import { useArchivedThreads } from "@/hooks/queries/thread-queries";
+import { TopLevelSidebarSection } from "./TopLevelSidebarSection";
+import { ThreadRow } from "./ThreadRow";
+
+export function ArchivedRows({
+ selectedThreadId,
+ onNavigate,
+}: {
+ selectedThreadId?: string;
+ onNavigate?: () => void;
+}) {
+ const query = useArchivedThreads({});
+ const threads = useMemo(
+ () => [
+ ...new Map(
+ (query.data?.pages.flat() ?? [])
+ .filter((thread) => thread.archivedAt !== null)
+ .map((thread) => [thread.id, thread]),
+ ).values(),
+ ],
+ [query.data],
+ );
+
+ return (
+
+ {threads.map((thread) => (
+
+ ))}
+ {query.isPending ? (
+
+ Loading archived threads…
+
+ ) : null}
+ {query.isError ? (
+
+ Archived threads could not load.
+
+
+ ) : null}
+ {!query.isPending && !query.isError && threads.length === 0 ? (
+
+ No archived threads.
+
+ ) : null}
+ {query.hasNextPage ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx b/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx
index 0f5350ac864..56a93754e9f 100644
--- a/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx
+++ b/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx
@@ -1,3 +1,4 @@
+import { createNewThreadDraft } from "@/lib/drafts/resource-runtime";
import type { ComponentProps } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -55,10 +56,13 @@ export function BuiltInSidebarNavigation({
/>
),
disabled: onNewChat === undefined,
- splitContent: { kind: "new-thread" },
+ splitContent: () => ({
+ kind: "new-thread",
+ draftId: createNewThreadDraft({}),
+ }),
onActivate: (event: SidebarNavActivationModifiers) => {
- if (event.metaKey || event.ctrlKey) {
- newThreadSplit?.openInSplit();
+ if (newThreadSplit && (event.metaKey || event.ctrlKey)) {
+ newThreadSplit.openInSplit();
return;
}
onNewChat?.();
diff --git a/apps/app/src/components/sidebar/DraftRows.test.tsx b/apps/app/src/components/sidebar/DraftRows.test.tsx
new file mode 100644
index 00000000000..9d9cff1d5f1
--- /dev/null
+++ b/apps/app/src/components/sidebar/DraftRows.test.tsx
@@ -0,0 +1,123 @@
+// @vitest-environment jsdom
+
+import type { ReactNode } from "react";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { Provider } from "jotai";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { draftContentSchema, type Draft } from "@bb/server-contract";
+import type { RecoverableDraftSnapshot } from "@/lib/drafts/resource-store";
+import { openDraftInSplit } from "@/lib/split-layout/openDraftInSplit";
+import { DraftRows } from "./DraftRows";
+
+const state = vi.hoisted(() => ({
+ remote: [] as Draft[],
+ local: [] as RecoverableDraftSnapshot[],
+}));
+
+vi.mock("@/hooks/queries/draft-queries", () => ({
+ useDrafts: () => ({
+ data: { pages: [{ drafts: state.remote, nextOffset: null }] },
+ isPending: false,
+ isError: false,
+ hasNextPage: false,
+ }),
+}));
+vi.mock("@/hooks/useDraftResource", () => ({
+ useRecoverableDrafts: () => state.local,
+}));
+vi.mock("./TopLevelSidebarSection", () => ({
+ TopLevelSidebarSection: ({
+ label,
+ children,
+ }: {
+ label: string;
+ children: ReactNode;
+ }) => ,
+}));
+vi.mock("./usePaneContentSplitDrag", () => ({
+ usePaneContentSplitDrag: () => ({}),
+}));
+vi.mock("./paneContentSplitIndicator", () => ({
+ usePaneContentSplitIndicator: () => ({ miniMap: null, isOpenInSplit: false }),
+}));
+vi.mock("@/lib/split-layout/openDraftInSplit", () => ({
+ openDraftInSplit: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ state.remote = [];
+ state.local = [];
+ vi.clearAllMocks();
+});
+
+describe("sidebar draft rows", () => {
+ it("keeps missing-project drafts recoverable and opens their preserved identity normally or in a split", () => {
+ state.remote = [
+ {
+ id: "drf_missingproject",
+ revision: 1,
+ createdAt: 1,
+ updatedAt: 2,
+ content: draftContentSchema.parse({
+ projectId: "proj_deleted",
+ prompt: { text: "Recover the plan" },
+ }),
+ },
+ ];
+ render(
+
+
+
+
+ ,
+ );
+ const row = screen.getByRole("link", {
+ name: /Recover the plan, draft, Project unavailable/,
+ });
+ expect(row.getAttribute("aria-current")).toBe("page");
+ expect(row.getAttribute("href")).toBe("/?draft=drf_missingproject");
+ expect(screen.getByText("Project unavailable")).not.toBeNull();
+ fireEvent.click(row);
+ expect(openDraftInSplit).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ draftId: "drf_missingproject",
+ split: "replace",
+ }),
+ );
+ fireEvent.click(row, { metaKey: true });
+ expect(openDraftInSplit).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ draftId: "drf_missingproject",
+ split: "right",
+ }),
+ );
+ });
+
+ it("shows a locally recovered message before it appears in the server list", () => {
+ state.local = [
+ {
+ id: "drf_pendingdraft",
+ content: draftContentSchema.parse({
+ prompt: { text: "Pending message" },
+ }),
+ updatedAt: 3,
+ status: "error",
+ error: new Error("Offline"),
+ persistenceError: null,
+ },
+ ];
+ render(
+
+
+
+
+ ,
+ );
+ expect(
+ screen.getByRole("link", { name: /Pending message, draft/ }),
+ ).not.toBeNull();
+ expect(screen.getByText("Not saved")).not.toBeNull();
+ });
+});
diff --git a/apps/app/src/components/sidebar/DraftRows.tsx b/apps/app/src/components/sidebar/DraftRows.tsx
new file mode 100644
index 00000000000..317f380efaf
--- /dev/null
+++ b/apps/app/src/components/sidebar/DraftRows.tsx
@@ -0,0 +1,210 @@
+import { useMemo } from "react";
+import { useStore } from "jotai";
+import { Link, useLocation, useNavigate } from "react-router-dom";
+import { PERSONAL_PROJECT_ID } from "@bb/domain";
+import type { ProjectResponse } from "@bb/server-contract";
+import { Button } from "@bb/shared-ui/button";
+import { Icon } from "@bb/shared-ui/icon";
+import { cn } from "@bb/shared-ui/lib/utils";
+import { COARSE_POINTER_ROW_HEIGHT_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
+import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
+import { useDrafts } from "@/hooks/queries/draft-queries";
+import { useRecoverableDrafts } from "@/hooks/useDraftResource";
+import { getDraftRoutePath, parseDraftRouteId } from "@/lib/draft-route";
+import {
+ getDraftDisplayTitle,
+ mergeDraftListEntries,
+ type DraftListEntry,
+} from "@/lib/drafts/draft-list";
+import { openDraftInSplit } from "@/lib/split-layout/openDraftInSplit";
+import { TopLevelSidebarSection } from "./TopLevelSidebarSection";
+import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag";
+import { usePaneContentSplitIndicator } from "./paneContentSplitIndicator";
+import { SplitPaneMiniMap } from "./SplitPaneMiniMap";
+import {
+ SIDEBAR_ROW_BASE_CLASS,
+ SIDEBAR_ROW_INTERACTIVE_STATE_CLASS,
+ SIDEBAR_ROW_OPEN_IN_SPLIT_STATE_CLASS,
+ SIDEBAR_ROW_SELECTED_STATE_CLASS,
+ SIDEBAR_STANDARD_ROW_PADDING_CLASS,
+} from "./sidebarRowClasses";
+
+function DraftRow({
+ draft,
+ projectName,
+ projectUnavailable,
+ isSelected,
+ onNavigate,
+}: {
+ draft: DraftListEntry;
+ projectName: string;
+ projectUnavailable: boolean;
+ isSelected: boolean;
+ onNavigate?: () => void;
+}) {
+ const title = getDraftDisplayTitle(draft);
+ const store = useStore();
+ const navigate = useNavigate();
+ const isCompact = useIsCompactViewport();
+ const content = { kind: "new-thread", draftId: draft.id } as const;
+ const split = usePaneContentSplitDrag({
+ content,
+ enabled: true,
+ label: title,
+ onNavigate,
+ });
+ const splitIndicator = usePaneContentSplitIndicator(content, true);
+ const recoveryLabel =
+ draft.recoveryStatus === "deleted"
+ ? "Recovery available"
+ : draft.recoveryStatus === "conflict"
+ ? "Conflicting changes"
+ : draft.recoveryStatus === "error"
+ ? "Not saved"
+ : projectUnavailable
+ ? "Project unavailable"
+ : null;
+
+ return (
+ {
+ if (event.button !== 0 || event.altKey || event.shiftKey) return;
+ event.preventDefault();
+ openDraftInSplit({
+ store,
+ navigate,
+ draftId: draft.id,
+ isCompact,
+ split: event.metaKey || event.ctrlKey ? "right" : "replace",
+ });
+ onNavigate?.();
+ }}
+ className={cn(
+ SIDEBAR_ROW_BASE_CLASS,
+ SIDEBAR_STANDARD_ROW_PADDING_CLASS,
+ COARSE_POINTER_ROW_HEIGHT_CLASS,
+ isSelected
+ ? SIDEBAR_ROW_SELECTED_STATE_CLASS
+ : SIDEBAR_ROW_INTERACTIVE_STATE_CLASS,
+ !isSelected &&
+ splitIndicator.isOpenInSplit &&
+ SIDEBAR_ROW_OPEN_IN_SPLIT_STATE_CLASS,
+ "pr-2 outline-none ring-sidebar-ring focus-visible:ring-2",
+ )}
+ >
+
+ {title}
+
+ {recoveryLabel ? (
+
+ {recoveryLabel}
+
+ ) : splitIndicator.miniMap ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export function DraftRows({
+ projects,
+ onNavigate,
+}: {
+ projects: readonly ProjectResponse[];
+ onNavigate?: () => void;
+}) {
+ const query = useDrafts();
+ const localDrafts = useRecoverableDrafts();
+ const location = useLocation();
+ const selectedDraftId =
+ location.pathname === "/" ? parseDraftRouteId(location.search) : null;
+ const drafts = useMemo(
+ () =>
+ mergeDraftListEntries(
+ query.data?.pages.flatMap((page) => page.drafts) ?? [],
+ localDrafts,
+ ),
+ [localDrafts, query.data],
+ );
+ const projectNames = useMemo(
+ () => new Map(projects.map((project) => [project.id, project.name])),
+ [projects],
+ );
+
+ return (
+
+ {drafts.map((draft) => {
+ const projectId = draft.content.projectId;
+ const projectName =
+ projectId === PERSONAL_PROJECT_ID
+ ? "Personal"
+ : projectId === null
+ ? "No project selected"
+ : (projectNames.get(projectId) ?? "Project unavailable");
+ return (
+
+ );
+ })}
+ {query.isPending && drafts.length === 0 ? (
+
+ Loading drafts…
+
+ ) : null}
+ {query.isError ? (
+
+ Saved drafts could not load.
+
+
+ ) : null}
+ {!query.isPending && !query.isError && drafts.length === 0 ? (
+
+ Add a message or attachment to a new thread to keep a draft here.
+
+ ) : null}
+ {query.hasNextPage ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx
index aa5a6de97c0..881946a1310 100644
--- a/apps/app/src/components/sidebar/ProjectList.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.tsx
@@ -1,5 +1,7 @@
import {
+ lazy,
memo,
+ Suspense,
useCallback,
useEffect,
useMemo,
@@ -48,11 +50,11 @@ import {
} from "@bb/client-core";
import { useSectionThreadDnd } from "./useSectionThreadDnd";
import { useRenderedSectionThreadDnd } from "./useRenderedSectionThreadDnd";
-import { getRootComposeRoutePath } from "@/lib/route-paths";
+import { getDraftRoutePath } from "@/lib/draft-route";
+import { createNewThreadDraft } from "@/lib/drafts/resource-runtime";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
import { BbHttpError } from "@bb/sdk/browser";
-import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { cn } from "@bb/shared-ui/lib/utils";
import { Button } from "@bb/shared-ui/button";
import {
@@ -132,12 +134,12 @@ import {
SidebarHeaderActionsProvider,
SidebarHeaderControls,
} from "./SidebarHeaderControls";
+import { LifecycleFilterMenu } from "@/components/thread/LifecycleFilterMenu";
+import { sidebarLifecycleFilterAtom } from "./sidebarLifecycleFilter";
import {
useAppCommandRunner,
useAppCommandShortcut,
} from "@/components/commands/AppCommandProvider";
-import { usePaneContentSplitIndicator } from "./paneContentSplitIndicator";
-import { SplitPaneMiniMap } from "./SplitPaneMiniMap";
import {
renderBuiltInSidebarSection,
SortableSidebarSection,
@@ -146,6 +148,13 @@ import {
} from "./BuiltInSidebarSection";
import { ReorderableSidebarSectionOrderList } from "./ReorderableSidebarSectionOrderList";
import { useSidebarModeSectionOrder } from "./useSidebarModeSectionOrder";
+
+const DraftRows = lazy(() =>
+ import("./DraftRows").then((module) => ({ default: module.DraftRows })),
+);
+const ArchivedRows = lazy(() =>
+ import("./ArchivedRows").then((module) => ({ default: module.ArchivedRows })),
+);
import { haveSameOrder } from "@/lib/stored-order";
import {
resolveThreadTitleDisplayText,
@@ -421,16 +430,11 @@ function ProjectListNavigationLoadingRow({
}
export function ProjectListNewThreadAction({
- splitEnabled = false,
newThreadSplit,
onNewChat,
}: ProjectListNewThreadActionProps) {
const isNewChatDisabled = !onNewChat;
const newThreadShortcut = useAppCommandShortcut("thread.new");
- const newThreadSplitIndicator = usePaneContentSplitIndicator(
- { kind: "new-thread" },
- splitEnabled,
- );
return (
@@ -1317,7 +1315,7 @@ function ProjectListComponent({
isCreatingProject = false,
}: ProjectListProps) {
const navigate = useNavigate();
- const setRootComposeProjectId = useSetRootComposeProjectId();
+ const [lifecycles, setLifecycles] = useAtom(sidebarLifecycleFilterAtom);
const sidebarNavigationQuery = useSidebarNavigation();
const sidebarNavigation = sidebarNavigationQuery.data;
const sections = sidebarNavigation?.sections ?? EMPTY_SECTION_DEFINITIONS;
@@ -1383,16 +1381,13 @@ function ProjectListComponent({
);
const openRootComposeForProject = useCallback(
(projectId: string, sectionId?: string) => {
- setRootComposeProjectId(projectId);
+ const draftId = createNewThreadDraft({ projectId, sectionId });
onProjectSelect?.();
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- ...(sectionId ? { sectionId } : {}),
- },
+ navigate(getDraftRoutePath(draftId), {
+ state: { focusPrompt: true },
});
},
- [navigate, onProjectSelect, setRootComposeProjectId],
+ [navigate, onProjectSelect],
);
const handleCreateProjectThread = useCallback(
(projectId: string) => {
@@ -1715,71 +1710,32 @@ function ProjectListComponent({
}}
>
- (
-
+
+
+ {lifecycles.includes("drafts") ? (
+
+ Loading drafts…
+
+ }
+ >
+
- )}
- renderChronological={() => (
- <>
-
- >
- )}
- renderProject={() => (
- <>
-
+ ) : null}
+ {lifecycles.includes("active") ? (
+ (
+
- >
- )}
- />
+ )}
+ renderChronological={() => (
+ <>
+
+ >
+ )}
+ renderProject={() => (
+ <>
+
+ >
+ )}
+ />
+ ) : null}
+ {lifecycles.includes("archived") ? (
+
+ Loading archived threads…
+
+ }
+ >
+
+
+ ) : null}
{sectionCreateDialog}
{sectionRenameDialogContent}
diff --git a/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx b/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx
index c8c928c5795..ada41bc3b39 100644
--- a/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx
+++ b/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx
@@ -12,7 +12,10 @@ import { createStore, Provider } from "jotai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
-import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence";
+import {
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+ SPLIT_LAYOUT_STORAGE_KEY,
+} from "@/lib/split-layout/persistence";
import {
resetPluginThreadRowStatusesForTest,
setPluginThreadRowStatus,
@@ -25,7 +28,9 @@ afterEach(() => {
vi.clearAllMocks();
resetPluginThreadRowStatusesForTest();
window.localStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY);
+ window.localStorage.removeItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY);
window.sessionStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY);
+ window.sessionStorage.removeItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY);
});
describe("SidebarControlButton", () => {
@@ -235,7 +240,7 @@ describe("TopLevelSidebarSection", () => {
{
type: "pane",
paneId: "pane-compose",
- content: { kind: "new-thread" },
+ content: { kind: "new-thread", draftId: "drf_sidebarfixture" },
},
],
},
diff --git a/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx b/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx
index 88f9622c23d..8ff3380cc7e 100644
--- a/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx
+++ b/apps/app/src/components/sidebar/SidebarNavigationRegion.test.tsx
@@ -22,6 +22,7 @@ import { makePluginRegistrationSet as registrationSet } from "@/test/fixtures/pl
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
openNewThreadInSplit: vi.fn(),
+ newThreadPointerDown: vi.fn(),
onSearchThreads: vi.fn(),
}));
@@ -112,7 +113,10 @@ function Harness({ onOwnerMount }: { onOwnerMount: () => void }) {
<>
{
vi.restoreAllMocks();
mocks.dispatch.mockReset();
mocks.openNewThreadInSplit.mockReset();
+ mocks.newThreadPointerDown.mockReset();
mocks.onSearchThreads.mockReset();
});
describe("SidebarNavigationRegion", () => {
+ it("uses the same fresh-draft split and drag actions in replacement navigation", () => {
+ registerFixture();
+ renderHarness();
+ const newThread = screen.getByRole("button", { name: "New thread" });
+ fireEvent.pointerDown(newThread, { button: 0 });
+ fireEvent.click(newThread, { metaKey: true });
+ expect(mocks.newThreadPointerDown).toHaveBeenCalledOnce();
+ expect(mocks.openNewThreadInSplit).toHaveBeenCalledOnce();
+ });
+
it("preserves modifier-click for New thread in BB navigation", () => {
renderHarness();
diff --git a/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx b/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx
index 3e8541df5ad..bd48085a87a 100644
--- a/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx
+++ b/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx
@@ -35,13 +35,11 @@ import { useSidebarNavigationReplacement } from "./sidebarNavigationProvider";
import { usePaneContentSplitActions } from "./usePaneContentSplitDrag";
const SIDEBAR_NAVIGATION_SLOT_KIND = "sidebarNavigation";
-const NEW_THREAD_CONTENT = { kind: "new-thread" } as const;
function contentForAction(
action: ExperimentalSidebarNavigationAction,
navPanels: ReturnType["navPanels"],
) {
- if (action.kind === "new-thread") return NEW_THREAD_CONTENT;
if (action.kind !== "open-plugin-panel") return null;
const panel = navPanels.find(
(candidate) =>
@@ -73,8 +71,14 @@ export function SidebarNavigationRegion(props: BuiltInSidebarNavigationProps) {
action: ExperimentalSidebarNavigationAction,
label: string,
): ExperimentalSidebarNavigationItem["experimental_splitProps"] => {
+ if (splitActions.isCompact) return {};
+ if (action.kind === "new-thread") {
+ return props.newThreadSplit?.onPointerDown
+ ? { onPointerDown: props.newThreadSplit.onPointerDown }
+ : {};
+ }
const content = contentForAction(action, navPanels);
- if (content === null || splitActions.isCompact) return {};
+ if (content === null) return {};
return {
onPointerDown: (event: ReactPointerEvent) =>
splitActions.beginDrag(event, {
@@ -85,7 +89,13 @@ export function SidebarNavigationRegion(props: BuiltInSidebarNavigationProps) {
}),
};
},
- [navPanels, props.onNavigate, props.splitEnabled, splitActions],
+ [
+ navPanels,
+ props.newThreadSplit,
+ props.onNavigate,
+ props.splitEnabled,
+ splitActions,
+ ],
);
const items = useMemo(
() =>
@@ -175,12 +185,11 @@ export function SidebarNavigationRegion(props: BuiltInSidebarNavigationProps) {
current.props.onNewChat?.();
return;
}
- current.splitActions.openInSplit({
- content: NEW_THREAD_CONTENT,
- enabled: current.props.splitEnabled ?? false,
- label: "New thread",
- onNavigate: current.props.onNavigate,
- });
+ if (current.props.newThreadSplit) {
+ current.props.newThreadSplit.openInSplit();
+ } else {
+ current.props.onNewChat?.();
+ }
},
searchThreads: () => {
current.props.onSearchThreads?.();
diff --git a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx
index 5eaec28fd5b..f9378372c83 100644
--- a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx
+++ b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx
@@ -579,7 +579,7 @@ export function SplitPageLabels() {
{
type: "pane",
paneId: "pane-compose",
- content: { kind: "new-thread" },
+ content: { kind: "new-thread", draftId: "drf_sidebarfixture" },
},
{
type: "pane",
diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx
index 4f78b78fdfd..b92db08dce7 100644
--- a/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx
+++ b/apps/app/src/components/sidebar/SidebarSectionRow.stories.tsx
@@ -83,7 +83,7 @@ function SplitViewSidebarStage({ children }: { children: ReactNode }) {
{
type: "pane",
paneId: "pane-compose",
- content: { kind: "new-thread" },
+ content: { kind: "new-thread", draftId: "drf_sidebarfixture" },
},
],
},
diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx
index f9a7a1b32ba..4ecbc8e97d9 100644
--- a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx
+++ b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx
@@ -5,7 +5,10 @@ import { createStore, Provider } from "jotai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
-import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence";
+import {
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+ SPLIT_LAYOUT_STORAGE_KEY,
+} from "@/lib/split-layout/persistence";
import {
resetPluginThreadRowStatusesForTest,
setPluginThreadRowStatus,
@@ -20,7 +23,9 @@ afterEach(() => {
cleanup();
resetPluginThreadRowStatusesForTest();
window.localStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY);
+ window.localStorage.removeItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY);
window.sessionStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY);
+ window.sessionStorage.removeItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY);
});
describe("SidebarSectionRow", () => {
@@ -79,7 +84,7 @@ describe("SidebarSectionRow", () => {
{
type: "pane",
paneId: "pane-compose",
- content: { kind: "new-thread" },
+ content: { kind: "new-thread", draftId: "drf_sidebarfixture" },
},
{
type: "pane",
diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx
index fcabe6163b4..44d66cd68a9 100644
--- a/apps/app/src/components/sidebar/ThreadRow.test.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx
@@ -49,7 +49,10 @@ import {
setPluginSlotRegistrations,
} from "@/lib/plugin-slots";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
-import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence";
+import {
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+ SPLIT_LAYOUT_STORAGE_KEY,
+} from "@/lib/split-layout/persistence";
import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core";
import { sdk } from "@/lib/sdk";
import { makeThreadListEntry as makeThreadListEntryFixture } from "@bb/test-helpers/domain-fixtures";
@@ -199,7 +202,7 @@ function renderSplitThreadRow({
{
type: "pane",
paneId: "pane-compose",
- content: { kind: "new-thread" },
+ content: { kind: "new-thread", draftId: "drf_sidebarfixture" },
},
],
},
@@ -225,7 +228,9 @@ afterEach(() => {
removePluginSlotRegistrations("icon-probe");
expect(vi.isMockFunction(sdk.threads.resolveMentions)).toBe(false);
window.localStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY);
+ window.localStorage.removeItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY);
window.sessionStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY);
+ window.sessionStorage.removeItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY);
});
describe("ThreadRow", () => {
diff --git a/apps/app/src/components/sidebar/sidebarLifecycleFilter.ts b/apps/app/src/components/sidebar/sidebarLifecycleFilter.ts
new file mode 100644
index 00000000000..ad20ac74678
--- /dev/null
+++ b/apps/app/src/components/sidebar/sidebarLifecycleFilter.ts
@@ -0,0 +1,5 @@
+import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom";
+
+export const sidebarLifecycleFilterAtom = createSyncedPreferenceAtom(
+ "sidebar.lifecycleFilter",
+);
diff --git a/apps/app/src/components/sidebar/usePaneContentSplitDrag.test.tsx b/apps/app/src/components/sidebar/usePaneContentSplitDrag.test.tsx
new file mode 100644
index 00000000000..5fb3eddb684
--- /dev/null
+++ b/apps/app/src/components/sidebar/usePaneContentSplitDrag.test.tsx
@@ -0,0 +1,211 @@
+// @vitest-environment jsdom
+
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+} from "@testing-library/react";
+import { createStore, Provider } from "jotai";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { splitLayoutAtom } from "@/lib/split-layout/atoms";
+import {
+ findPane,
+ listPanes,
+ splitPane,
+ type PaneContent,
+} from "@/lib/split-layout";
+import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag";
+import type { beginSplitDrag } from "@/lib/split-drag";
+import { createSinglePaneLayout } from "@/views/thread-detail/splitThreadNavigation";
+
+const mocks = vi.hoisted(() => ({
+ navigate: vi.fn(),
+ begin: vi.fn(),
+ isCompact: vi.fn(() => false),
+}));
+vi.mock("react-router-dom", async (original) => ({
+ ...(await original()),
+ useNavigate: () => mocks.navigate,
+}));
+vi.mock("@/lib/split-drag", async (original) => ({
+ ...(await original()),
+ beginSplitDrag: mocks.begin,
+}));
+vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({
+ useIsCompactViewport: mocks.isCompact,
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ mocks.isCompact.mockReturnValue(false);
+ window.sessionStorage.clear();
+ window.localStorage.clear();
+});
+
+function DraftAction({
+ content,
+}: {
+ content: PaneContent | (() => PaneContent);
+}) {
+ const actions = usePaneContentSplitDrag({
+ content,
+ enabled: true,
+ label: "New thread",
+ });
+ return (
+
+ );
+}
+
+function startDrag() {
+ fireEvent(
+ screen.getByRole("button", { name: "Open draft" }),
+ new MouseEvent("pointerdown", {
+ bubbles: true,
+ button: 0,
+ clientX: 10,
+ clientY: 10,
+ }),
+ );
+ const config = mocks.begin.mock.calls[0]?.[0];
+ if (config === undefined) throw new Error("Expected a split drag session");
+ return config;
+}
+
+describe("fresh draft split drag", () => {
+ it.each([
+ { mode: "split", compact: false, enabled: true },
+ { mode: "compact fallback", compact: true, enabled: true },
+ { mode: "disabled split fallback", compact: false, enabled: false },
+ ])(
+ "defers allocation and opens an editable draft through $mode",
+ ({ compact, enabled }) => {
+ mocks.isCompact.mockReturnValue(compact);
+ const store = createStore();
+ const initialLayout = createSinglePaneLayout({
+ projectId: "p1",
+ threadId: "t1",
+ });
+ store.set(splitLayoutAtom, initialLayout);
+ const create = vi.fn(
+ () => ({ kind: "new-thread", draftId: "drf_fresh_open" }) as const,
+ );
+ const { result, rerender } = renderHook(
+ () =>
+ usePaneContentSplitDrag({
+ content: create,
+ enabled,
+ label: "New thread",
+ }),
+ {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ },
+ );
+ rerender();
+ expect(create).not.toHaveBeenCalled();
+ act(() => result.current.openInSplit());
+ expect(create).toHaveBeenCalledTimes(1);
+ expect(mocks.navigate).toHaveBeenCalledWith("/?draft=drf_fresh_open", {
+ state: { focusPrompt: true },
+ });
+ const layout = store.get(splitLayoutAtom)!;
+ if (compact || !enabled) {
+ expect(layout).toEqual(initialLayout);
+ } else {
+ expect(listPanes(layout.root)).toHaveLength(2);
+ expect(findPane(layout.root, "pane-1")).toEqual(initialLayout.root);
+ expect(findPane(layout.root, layout.focusedPaneId)?.content).toEqual({
+ kind: "new-thread",
+ draftId: "drf_fresh_open",
+ });
+ }
+ },
+ );
+
+ it.each(["right", "center"] as const)(
+ "starts editing only when a fresh draft drop commits to %s",
+ (zone) => {
+ const store = createStore();
+ const initialLayout = createSinglePaneLayout({
+ projectId: "p1",
+ threadId: "t1",
+ });
+ store.set(splitLayoutAtom, initialLayout);
+ const create = vi.fn(
+ () => ({ kind: "new-thread", draftId: "drf_fresh_drop" }) as const,
+ );
+ render(
+
+
+ ,
+ );
+
+ const drag = startDrag();
+ expect(drag.decide("pane-1", zone)).not.toBeNull();
+ expect(create).not.toHaveBeenCalled();
+ expect(mocks.navigate).not.toHaveBeenCalled();
+ act(() => drag.onDrop({ paneId: "pane-1", zone }));
+
+ expect(create).toHaveBeenCalledTimes(1);
+ expect(mocks.navigate).toHaveBeenCalledWith("/?draft=drf_fresh_drop", {
+ state: { focusPrompt: true },
+ });
+ const layout = store.get(splitLayoutAtom)!;
+ expect(listPanes(layout.root)).toHaveLength(zone === "right" ? 2 : 1);
+ expect(findPane(layout.root, layout.focusedPaneId)?.content).toEqual({
+ kind: "new-thread",
+ draftId: "drf_fresh_drop",
+ });
+ if (zone === "right")
+ expect(findPane(layout.root, "pane-1")).toEqual(initialLayout.root);
+ },
+ );
+
+ it.each(["open", "drop"] as const)(
+ "focuses an existing draft on %s without restarting composition",
+ (activation) => {
+ const store = createStore();
+ const content: PaneContent = {
+ kind: "new-thread",
+ draftId: "drf_saved_draft",
+ };
+ const layout = splitPane(
+ createSinglePaneLayout({ projectId: "p1", threadId: "t1" }),
+ "pane-1",
+ "right",
+ content,
+ );
+ const draftPaneId = layout.focusedPaneId;
+ store.set(splitLayoutAtom, { ...layout, focusedPaneId: "pane-1" });
+ render(
+
+
+ ,
+ );
+
+ if (activation === "open") {
+ fireEvent.click(screen.getByRole("button", { name: "Open draft" }));
+ } else {
+ const drag = startDrag();
+ act(() => drag.onDrop({ paneId: "pane-1", zone: "center" }));
+ }
+
+ expect(store.get(splitLayoutAtom)).toEqual({
+ ...layout,
+ focusedPaneId: draftPaneId,
+ });
+ expect(mocks.navigate).toHaveBeenCalledWith("/?draft=drf_saved_draft", {
+ replace: true,
+ });
+ },
+ );
+});
diff --git a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts
index 588e548f8f7..a87f75366e1 100644
--- a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts
+++ b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts
@@ -8,10 +8,10 @@ import { useNavigate } from "react-router-dom";
import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import {
getPluginPanelRoutePath,
- getRootComposeRoutePath,
getThreadRoutePath,
getPluginDetailRoutePath,
} from "@/lib/route-paths";
+import { getDraftRoutePath } from "@/lib/draft-route";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit";
import {
@@ -37,7 +37,7 @@ const MAIN_CONTENT_SELECTOR = "main";
function routeForContent(content: PaneContent): string {
if (content.kind === "thread") return getThreadRoutePath(content);
- if (content.kind === "new-thread") return getRootComposeRoutePath();
+ if (content.kind === "new-thread") return getDraftRoutePath(content.draftId);
if (content.kind === "plugin-detail") {
return getPluginDetailRoutePath({ pluginId: content.pluginId });
}
@@ -68,7 +68,7 @@ export function usePaneContentSplitDrag(options: PaneContentSplitOptions) {
}
interface PaneContentSplitOptions {
- content: PaneContent;
+ content: PaneContent | (() => PaneContent);
enabled: boolean;
label: string;
onNavigate?: () => void;
@@ -82,11 +82,23 @@ export function usePaneContentSplitActions() {
const isCompact = useIsCompactViewport();
const openInSplit = useCallback(
- ({ content, enabled, onNavigate }: PaneContentSplitOptions) => {
+ ({ content: source, enabled, onNavigate }: PaneContentSplitOptions) => {
+ const content = typeof source === "function" ? source() : source;
+ const isFreshDraft =
+ typeof source === "function" && content.kind === "new-thread";
onNavigate?.();
openPaneContentInSplit({
store,
- navigate,
+ navigate: (route, options) =>
+ navigate(
+ route,
+ isFreshDraft
+ ? {
+ ...options,
+ state: { focusPrompt: true },
+ }
+ : options,
+ ),
content,
route: routeForContent(content),
enabled: enabled && !isCompact,
@@ -133,9 +145,9 @@ interface BeginSidebarPaneContentSplitDragArgs {
store: ReturnType;
navigate: (
route: string,
- options?: { replace?: boolean },
+ options?: { replace?: boolean; state?: { focusPrompt: boolean } },
) => void | Promise;
- content: PaneContent;
+ content: PaneContent | (() => PaneContent);
label: string;
onNavigate?: () => void;
onDragStart?: () => void;
@@ -181,26 +193,32 @@ export function beginSidebarPaneContentSplitDrag({
if (layout === null) return null;
return decideThreadDrop({
zone,
- threadAlreadyOpen: findPaneByContent(layout.root, content) !== null,
+ threadAlreadyOpen:
+ typeof content !== "function" &&
+ findPaneByContent(layout.root, content) !== null,
atMaxPanes: countPanes(layout.root) >= MAX_PANES,
});
},
onDrop: (target) => {
const layout = store.get(splitLayoutAtom);
if (layout === null) return;
- const existing = findPaneByContent(layout.root, content);
+ const resolvedContent =
+ typeof content === "function" ? content() : content;
+ const isFreshDraft =
+ typeof content === "function" && resolvedContent.kind === "new-thread";
+ const existing = findPaneByContent(layout.root, resolvedContent);
const next =
existing !== null
? setFocus(layout, existing.paneId)
: target.zone === "center"
- ? replacePaneContent(layout, target.paneId, content)
- : splitPane(layout, target.paneId, target.zone, content);
+ ? replacePaneContent(layout, target.paneId, resolvedContent)
+ : splitPane(layout, target.paneId, target.zone, resolvedContent);
if (next !== layout) store.set(splitLayoutAtom, next);
onNavigate?.();
- void navigate(
- routeForContent(content),
- existing !== null ? { replace: true } : undefined,
- );
+ void navigate(routeForContent(resolvedContent), {
+ ...(existing !== null ? { replace: true } : {}),
+ ...(isFreshDraft ? { state: { focusPrompt: true } } : {}),
+ });
},
});
}
diff --git a/apps/app/src/components/thread/LifecycleFilterMenu.test.tsx b/apps/app/src/components/thread/LifecycleFilterMenu.test.tsx
new file mode 100644
index 00000000000..d2bfa023304
--- /dev/null
+++ b/apps/app/src/components/thread/LifecycleFilterMenu.test.tsx
@@ -0,0 +1,75 @@
+// @vitest-environment jsdom
+
+import { useState } from "react";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import type { ThreadLifecycle } from "@bb/domain";
+import { LifecycleFilterMenu } from "./LifecycleFilterMenu";
+
+function Filter() {
+ const [value, setValue] = useState(["active"]);
+ return (
+
+ );
+}
+
+afterEach(cleanup);
+
+describe("lifecycle filter", () => {
+ it("keeps one lifecycle selected, retains multiple selections on reopen, and resets to Active", async () => {
+ render();
+ fireEvent.keyDown(
+ screen.getByRole("button", { name: "Sidebar thread lifecycle: Active" }),
+ { key: "Enter" },
+ );
+ const active = await screen.findByRole("menuitemcheckbox", {
+ name: "Active",
+ });
+ expect(active.getAttribute("aria-disabled")).toBe("true");
+ fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Drafts" }));
+ expect(
+ screen
+ .getByRole("menuitemcheckbox", { name: "Drafts" })
+ .getAttribute("aria-checked"),
+ ).toBe("true");
+ fireEvent.click(active);
+ expect(
+ screen
+ .getByRole("menuitemcheckbox", { name: "Drafts" })
+ .getAttribute("aria-disabled"),
+ ).toBe("true");
+ fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" }));
+ fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
+ await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
+ fireEvent.keyDown(
+ screen.getByRole("button", {
+ name: "Sidebar thread lifecycle: Drafts, Archived",
+ }),
+ { key: "Enter" },
+ );
+ expect(
+ (
+ await screen.findByRole("menuitemcheckbox", { name: "Drafts" })
+ ).getAttribute("aria-checked"),
+ ).toBe("true");
+ expect(
+ screen
+ .getByRole("menuitemcheckbox", { name: "Archived" })
+ .getAttribute("aria-checked"),
+ ).toBe("true");
+ fireEvent.click(screen.getByRole("menuitem", { name: "Reset to Active" }));
+ await screen.findByRole("button", {
+ name: "Sidebar thread lifecycle: Active",
+ });
+ });
+});
diff --git a/apps/app/src/components/thread/LifecycleFilterMenu.tsx b/apps/app/src/components/thread/LifecycleFilterMenu.tsx
new file mode 100644
index 00000000000..2a4faf73b98
--- /dev/null
+++ b/apps/app/src/components/thread/LifecycleFilterMenu.tsx
@@ -0,0 +1,75 @@
+import type { ThreadLifecycle } from "@bb/domain";
+import { Button } from "@bb/shared-ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@bb/shared-ui/dropdown-menu";
+import { Icon } from "@bb/shared-ui/icon";
+import {
+ THREAD_LIFECYCLE_OPTIONS,
+ toggleThreadLifecycle,
+} from "@/lib/thread-lifecycle-filter";
+
+interface LifecycleFilterMenuProps {
+ label: string;
+ value: readonly ThreadLifecycle[];
+ onChange: (value: ThreadLifecycle[]) => void;
+}
+
+export function LifecycleFilterMenu({
+ label,
+ value,
+ onChange,
+}: LifecycleFilterMenuProps) {
+ const selectedLabel = THREAD_LIFECYCLE_OPTIONS.filter((option) =>
+ value.includes(option.value),
+ )
+ .map((option) => option.label)
+ .join(", ");
+ const isDefault = value.length === 1 && value[0] === "active";
+
+ return (
+
+
+
+
+
+ {THREAD_LIFECYCLE_OPTIONS.map((option) => (
+ event.preventDefault()}
+ onCheckedChange={() =>
+ onChange(toggleThreadLifecycle(value, option.value))
+ }
+ >
+ {option.label}
+
+ ))}
+ {!isDefault ? (
+ <>
+
+ onChange(["active"])}>
+ Reset to Active
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx
index 6ffa2858373..631272c3e49 100644
--- a/apps/app/src/components/thread/ThreadActionsProvider.tsx
+++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx
@@ -1,3 +1,4 @@
+import { paneContentRoute } from "@/views/thread-detail/splitThreadNavigation";
import {
createContext,
useCallback,
@@ -138,9 +139,12 @@ export function ThreadActionsProvider({
const syncNavigationAfterClose = useCallback(
(result: ClosePanesForThreadsResult, navigateAway: () => void) => {
- if (result.removedAny && result.focusedRoute !== null) {
- if (result.focusedRoute.threadId !== viewedThreadIdRef.current) {
- navigate(getThreadRoutePath(result.focusedRoute), { replace: true });
+ if (result.removedAny && result.focusedContent !== null) {
+ if (
+ result.focusedContent.kind !== "thread" ||
+ result.focusedContent.threadId !== viewedThreadIdRef.current
+ ) {
+ navigate(paneContentRoute(result.focusedContent), { replace: true });
}
return;
}
diff --git a/apps/app/src/components/tools/SkillsLibrary.tsx b/apps/app/src/components/tools/SkillsLibrary.tsx
index 833b013ea2f..a38247c7218 100644
--- a/apps/app/src/components/tools/SkillsLibrary.tsx
+++ b/apps/app/src/components/tools/SkillsLibrary.tsx
@@ -1,3 +1,4 @@
+import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
matchPath,
@@ -50,7 +51,6 @@ import type { RegistryRanking, RegistrySkill } from "@/lib/skills-registry";
import {
getRegistrySkillDetailRoutePath,
getRegistrySkillsRoutePath,
- getRootComposeRoutePath,
getSkillDetailRoutePath,
getSkillsRoutePath,
} from "@/lib/route-paths";
@@ -162,6 +162,7 @@ export function SkillsLibrary() {
const providerRoster = useProviderRoster();
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const openNewDraft = useOpenNewThreadDraft();
const location = useLocation();
const { skillId: routeSkillId, registrySkillId: routeRegistrySkillId } =
useParams<{
@@ -428,19 +429,22 @@ export function SkillsLibrary() {
);
const editSkillViaThread = useCallback(
(skill: SkillSummary) => {
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- initialPrompt: buildSkillEditThreadPrompt({
- id: skill.id,
- name: skill.name,
- path: skill.filePath,
- }),
- replaceInitialPrompt: true,
+ openNewDraft(
+ {},
+ {
+ state: {
+ focusPrompt: true,
+ initialPrompt: buildSkillEditThreadPrompt({
+ id: skill.id,
+ name: skill.name,
+ path: skill.filePath,
+ }),
+ replaceInitialPrompt: true,
+ },
},
- });
+ );
},
- [navigate],
+ [openNewDraft],
);
const openRegistrySkill = useCallback(
(skill: RegistrySkill) => {
@@ -467,29 +471,35 @@ export function SkillsLibrary() {
}, [navigate]);
const handleCreateSkill = useCallback(
(prompt?: string) => {
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- initialPrompt: prompt ?? CREATE_SKILL_PROMPT,
- replaceInitialPrompt: true,
- createDraftKind: "skill",
+ openNewDraft(
+ {},
+ {
+ state: {
+ focusPrompt: true,
+ initialPrompt: prompt ?? CREATE_SKILL_PROMPT,
+ replaceInitialPrompt: true,
+ createDraftKind: "skill",
+ },
},
- });
+ );
},
- [navigate],
+ [openNewDraft],
);
const forkRegistrySkill = useCallback(
(skill: RegistrySkill) => {
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- initialPrompt: buildRegistrySkillReferencePrompt(skill),
- replaceInitialPrompt: true,
- createDraftKind: "skill",
+ openNewDraft(
+ {},
+ {
+ state: {
+ focusPrompt: true,
+ initialPrompt: buildRegistrySkillReferencePrompt(skill),
+ replaceInitialPrompt: true,
+ createDraftKind: "skill",
+ },
},
- });
+ );
},
- [navigate],
+ [openNewDraft],
);
const registryDetail = registryDetailQuery.data ?? null;
const selectedLocalRegistrySkill = selectedRegistrySkill
diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx
index a9cfd631970..590a85c3688 100644
--- a/apps/app/src/components/ui/app-route-anchor.tsx
+++ b/apps/app/src/components/ui/app-route-anchor.tsx
@@ -115,7 +115,7 @@ export function RouteNavigationProvider({
);
const openInSplit = useCallback(
(path) => {
- const content = paneContentForPathname(path.split(/[?#]/)[0] ?? path);
+ const content = paneContentForPathname(path);
if (content === null) return false;
openPaneContentInSplit({
store,
@@ -196,9 +196,7 @@ export function useRouteAnchorDelegate(): (
href: anchor.getAttribute("href") ?? "",
});
if (route === null) return;
- const content = paneContentForPathname(
- route.path.split(/[?#]/)[0] ?? route.path,
- );
+ const content = paneContentForPathname(route.path);
if (
content?.kind === "plugin-detail" &&
openPluginDetail?.(content.pluginId)
diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
index 0cac2043d79..490936ee2dc 100644
--- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
+++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
@@ -73,6 +73,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"threadTimelineTurnSummaryDetailsQueryKeyPrefix",
"threadsQueryKey",
],
+ "hooks/cache-owners/draft-cache-owner.ts": ["allDraftQueryKeyPrefix"],
"hooks/cache-owners/environment-cache-effects.ts": [
"environmentDiffFilesQueryKeyPrefix",
"environmentFilePreviewQueryKeyPrefix",
diff --git a/apps/app/src/hooks/cache-owners/draft-cache-owner.ts b/apps/app/src/hooks/cache-owners/draft-cache-owner.ts
new file mode 100644
index 00000000000..86775b313c6
--- /dev/null
+++ b/apps/app/src/hooks/cache-owners/draft-cache-owner.ts
@@ -0,0 +1,30 @@
+import type { QueryClient } from "@tanstack/react-query";
+import type { Draft } from "@bb/server-contract";
+import { draftResourceQueryKey } from "@/lib/drafts/resource-api";
+import { allDraftQueryKeyPrefix } from "../queries/query-keys";
+
+export async function cacheDraftResource(
+ queryClient: QueryClient,
+ id: string,
+ draft: Draft | null,
+): Promise {
+ const queryKey = draftResourceQueryKey(id);
+ await queryClient.cancelQueries({ queryKey, exact: true });
+ const current = queryClient.getQueryData(queryKey);
+ if (draft && current && current.revision > draft.revision) return false;
+ queryClient.setQueryData(queryKey, draft);
+ return true;
+}
+
+export function invalidateDraftLists(queryClient: QueryClient): Promise {
+ return queryClient.invalidateQueries({
+ queryKey: allDraftQueryKeyPrefix(),
+ predicate: (query) => query.queryKey[1] !== "detail",
+ });
+}
+
+export function invalidateDraftResources(
+ queryClient: QueryClient,
+): Promise {
+ return queryClient.invalidateQueries({ queryKey: allDraftQueryKeyPrefix() });
+}
diff --git a/apps/app/src/hooks/queries/draft-queries.test.tsx b/apps/app/src/hooks/queries/draft-queries.test.tsx
new file mode 100644
index 00000000000..e3b1955585e
--- /dev/null
+++ b/apps/app/src/hooks/queries/draft-queries.test.tsx
@@ -0,0 +1,69 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { draftContentSchema, type Draft } from "@bb/server-contract";
+import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
+import { listDraftResources } from "@/lib/drafts/resource-api";
+import { allDraftQueryKeyPrefix } from "./query-keys";
+import { useDrafts } from "./draft-queries";
+
+vi.mock("@/lib/drafts/resource-api", () => ({
+ draftResourceListQueryKey: (query: object) => ["drafts", "list", query],
+ listDraftResources: vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.resetAllMocks();
+});
+
+const saved: Draft = {
+ id: "drf_savedfixture",
+ revision: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ content: draftContentSchema.parse({ prompt: { text: "Saved draft" } }),
+};
+
+describe("useDrafts", () => {
+ it("does not read drafts when disabled", () => {
+ const { wrapper } = createQueryClientTestHarness();
+ renderHook(() => useDrafts({}, { enabled: false }), { wrapper });
+ expect(listDraftResources).not.toHaveBeenCalled();
+ });
+
+ it("uses server pagination and refreshes under the shared realtime invalidation prefix", async () => {
+ vi.mocked(listDraftResources)
+ .mockResolvedValueOnce({ drafts: [saved], nextOffset: 12 })
+ .mockResolvedValueOnce({ drafts: [], nextOffset: null })
+ .mockResolvedValue({ drafts: [saved], nextOffset: null });
+ const { wrapper, queryClient } = createQueryClientTestHarness();
+ const { result } = renderHook(() => useDrafts({ query: "Saved" }), {
+ wrapper,
+ });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(listDraftResources).toHaveBeenNthCalledWith(
+ 1,
+ { query: "Saved", limit: "50", offset: "0" },
+ expect.any(AbortSignal),
+ );
+ expect(result.current.hasNextPage).toBe(true);
+ await act(async () => {
+ await result.current.fetchNextPage();
+ });
+ expect(listDraftResources).toHaveBeenNthCalledWith(
+ 2,
+ { query: "Saved", limit: "50", offset: "12" },
+ expect.any(AbortSignal),
+ );
+ await waitFor(() => expect(result.current.hasNextPage).toBe(false));
+ await act(async () => {
+ await queryClient.invalidateQueries({
+ queryKey: allDraftQueryKeyPrefix(),
+ });
+ });
+ expect(listDraftResources).toHaveBeenCalledTimes(3);
+ queryClient.clear();
+ });
+});
diff --git a/apps/app/src/hooks/queries/draft-queries.ts b/apps/app/src/hooks/queries/draft-queries.ts
new file mode 100644
index 00000000000..a524fe29a57
--- /dev/null
+++ b/apps/app/src/hooks/queries/draft-queries.ts
@@ -0,0 +1,24 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import type { DraftListQuery } from "@bb/server-contract";
+import {
+ draftResourceListQueryKey,
+ listDraftResources,
+} from "@/lib/drafts/resource-api";
+
+const DRAFTS_PAGE_SIZE = 50;
+
+export function useDrafts(
+ query: Omit = {},
+ options?: { enabled?: boolean },
+) {
+ const filters = { limit: String(DRAFTS_PAGE_SIZE), ...query };
+ return useInfiniteQuery({
+ queryKey: draftResourceListQueryKey(filters),
+ queryFn: ({ pageParam, signal }) =>
+ listDraftResources({ ...filters, offset: String(pageParam) }, signal),
+ initialPageParam: 0,
+ getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
+ enabled: options?.enabled ?? true,
+ staleTime: 10_000,
+ });
+}
diff --git a/apps/app/src/hooks/thread-creation-options/model-catalog-selection.ts b/apps/app/src/hooks/thread-creation-options/model-catalog-selection.ts
index aefadbea542..e8e1ebf665c 100644
--- a/apps/app/src/hooks/thread-creation-options/model-catalog-selection.ts
+++ b/apps/app/src/hooks/thread-creation-options/model-catalog-selection.ts
@@ -17,6 +17,7 @@ interface ResolveModelCatalogSelectionArgs {
preferredReasoningLevel?: ReasoningLevel;
provider: ReasoningLabelSource | undefined;
catalogIsVerified: boolean;
+ preserveUnavailableSelection?: boolean;
formatModelLabel: (displayName: string) => string;
}
@@ -65,6 +66,7 @@ export function resolveModelCatalogSelection({
preferredReasoningLevel,
provider,
catalogIsVerified,
+ preserveUnavailableSelection = false,
formatModelLabel,
}: ResolveModelCatalogSelectionArgs): ResolvedModelCatalogSelection {
const fullCatalog = [...models, ...selectedOnlyModels];
@@ -93,6 +95,8 @@ export function resolveModelCatalogSelection({
}
const selectedModel = (() => {
+ if (preserveUnavailableSelection && selectedModelSelection)
+ return selectedModelSelection;
if (!catalogIsVerified && selectedModelSelection) {
return selectedModelSelection;
}
@@ -112,8 +116,10 @@ export function resolveModelCatalogSelection({
const activeModel =
availableModels.find((model) => model.model === selectedModel) ??
- availableModels.find((model) => model.isDefault) ??
- availableModels[0];
+ (preserveUnavailableSelection && selectedModelSelection
+ ? undefined
+ : (availableModels.find((model) => model.isDefault) ??
+ availableModels[0]));
const reasoningOptions: PickerOption[] = [];
const seenReasoningLevels = new Set();
@@ -127,10 +133,9 @@ export function resolveModelCatalogSelection({
}
const preferredLevel = preferredReasoningLevel ?? "medium";
- const reasoningLevel = resolveModelReasoningLevel(
- activeModel,
- preferredLevel,
- );
+ const reasoningLevel = preserveUnavailableSelection
+ ? preferredLevel
+ : resolveModelReasoningLevel(activeModel, preferredLevel);
return {
selectedModel,
diff --git a/apps/app/src/hooks/thread-creation-options/selection-state.ts b/apps/app/src/hooks/thread-creation-options/selection-state.ts
index 240d68d3723..c476c3dd7aa 100644
--- a/apps/app/src/hooks/thread-creation-options/selection-state.ts
+++ b/apps/app/src/hooks/thread-creation-options/selection-state.ts
@@ -36,6 +36,7 @@ export interface UsePromptModelReasoningOptions {
resetKey?: string | number | null;
initialProviderId?: string;
preferReadyProviderWhenUnset?: boolean;
+ preserveUnavailableSelections?: boolean;
initialModel?: string;
initialServiceTier?: ServiceTier;
initialReasoningLevel?: ReasoningLevel;
diff --git a/apps/app/src/hooks/useCreateThreadInEnvironment.test.ts b/apps/app/src/hooks/useCreateThreadInEnvironment.test.ts
index 14c80c0ff20..214860952bd 100644
--- a/apps/app/src/hooks/useCreateThreadInEnvironment.test.ts
+++ b/apps/app/src/hooks/useCreateThreadInEnvironment.test.ts
@@ -16,6 +16,11 @@ vi.mock("@/components/ui/app-route-anchor", () => ({
vi.mock("@/lib/root-compose-selection", () => ({
useSetRootComposeProjectId: () => vi.fn(),
+ useRootComposeProjectId: () => ["proj_personal", vi.fn()],
+}));
+
+vi.mock("@/lib/drafts/resource-runtime", () => ({
+ createNewThreadDraft: () => "drf_environment_fixture",
}));
describe("useCreateThreadInEnvironment", () => {
@@ -30,6 +35,7 @@ describe("useCreateThreadInEnvironment", () => {
result.current();
+ expect(navigate.mock.calls[0][0]).toBe("/?draft=drf_environment_fixture");
const state = navigate.mock.calls[0][1].state;
expect(shouldStartComposingFromLocationState(state)).toBe(true);
expect(hasSingleUseRootComposeTargetState(state)).toBe(true);
diff --git a/apps/app/src/hooks/useCreateThreadInEnvironment.ts b/apps/app/src/hooks/useCreateThreadInEnvironment.ts
index 7588c0a3b41..4fee4a5eb51 100644
--- a/apps/app/src/hooks/useCreateThreadInEnvironment.ts
+++ b/apps/app/src/hooks/useCreateThreadInEnvironment.ts
@@ -1,6 +1,5 @@
+import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft";
import { useCallback } from "react";
-import { useRouteNavigate } from "@/components/ui/app-route-anchor";
-import { getRootComposeRoutePath } from "@/lib/route-paths";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
interface UseCreateThreadInEnvironmentArgs {
@@ -12,12 +11,15 @@ export function useCreateThreadInEnvironment({
projectId,
environmentId,
}: UseCreateThreadInEnvironmentArgs): () => void {
- const navigate = useRouteNavigate();
+ const openNewDraft = useOpenNewThreadDraft();
const setRootComposeProjectId = useSetRootComposeProjectId();
return useCallback(() => {
setRootComposeProjectId(projectId);
- navigate(getRootComposeRoutePath(), {
- state: { focusPrompt: true, reuseEnvironmentId: environmentId },
- });
- }, [environmentId, navigate, projectId, setRootComposeProjectId]);
+ openNewDraft(
+ { projectId },
+ {
+ state: { focusPrompt: true, reuseEnvironmentId: environmentId },
+ },
+ );
+ }, [environmentId, openNewDraft, projectId, setRootComposeProjectId]);
}
diff --git a/apps/app/src/hooks/useDraftResource.test.tsx b/apps/app/src/hooks/useDraftResource.test.tsx
new file mode 100644
index 00000000000..3c773f81ab5
--- /dev/null
+++ b/apps/app/src/hooks/useDraftResource.test.tsx
@@ -0,0 +1,258 @@
+// @vitest-environment jsdom
+
+import type { ReactNode } from "react";
+import { webcrypto } from "node:crypto";
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { draftContentSchema, type Draft } from "@bb/server-contract";
+import { useDraftResource } from "./useDraftResource";
+import { getDraftResourceStore } from "@/lib/drafts/resource-runtime";
+import {
+ draftResourceApi,
+ draftResourceQueryKey,
+} from "@/lib/drafts/resource-api";
+import {
+ browserDraftRecoveryStorage,
+ readDraftRecoveries,
+} from "@/lib/drafts/recovery";
+import { DraftResourceStore } from "@/lib/drafts/resource-store";
+import {
+ importLegacyNewThreadDraft,
+ LEGACY_DRAFT_IMPORT_PREFIX,
+ LEGACY_NEW_THREAD_DRAFT_KEY,
+} from "@/lib/drafts/legacy-import";
+
+const id = "drf_hook_resource";
+const content = draftContentSchema.parse({
+ prompt: { text: "Saved contents" },
+});
+const saved: Draft = { id, content, revision: 1, createdAt: 1, updatedAt: 1 };
+const stores: DraftResourceStore[] = [];
+const clients: QueryClient[] = [];
+
+beforeEach(() => {
+ vi.stubGlobal("crypto", webcrypto);
+});
+
+function createClient() {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: Infinity, gcTime: Infinity },
+ },
+ });
+ clients.push(client);
+ return client;
+}
+
+function stubErrorResponse(code: string) {
+ const network = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ code,
+ message:
+ code === "draft_gone"
+ ? "Draft was deleted or submitted"
+ : "This draft revision was submitted, but its thread has since been deleted",
+ ...(code === "draft_submitted_thread_gone"
+ ? { details: { threadId: "thr_submitted" } }
+ : {}),
+ }),
+ { status: 410, headers: { "content-type": "application/json" } },
+ ),
+ );
+ vi.stubGlobal("fetch", network);
+ return network;
+}
+
+afterEach(() => {
+ cleanup();
+ for (const store of stores.splice(0)) store.dispose();
+ for (const client of clients.splice(0)) client.clear();
+ localStorage.clear();
+ vi.unstubAllGlobals();
+});
+
+describe("mounted draft resource snapshots", () => {
+ it("keeps snapshots stable across observer option changes and updates once for changed data", async () => {
+ const queryClient = createClient();
+ queryClient.setQueryData(draftResourceQueryKey(id), saved);
+ const store = getDraftResourceStore(queryClient);
+ stores.push(store);
+ const initialSnapshot = store.getSnapshot(id);
+ const listener = vi.fn();
+ const unsubscribe = store.subscribe(id, listener);
+ let observerOptionUpdates = 0;
+ const unsubscribeCache = queryClient.getQueryCache().subscribe((event) => {
+ if (event.type === "observerOptionsUpdated") observerOptionUpdates += 1;
+ });
+ let renders = 0;
+ function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ }
+ const { result, rerender } = renderHook(
+ ({ renderKey }) => {
+ renders += 1;
+ if (renders > 20) throw new Error("Draft observer rerender loop");
+ const resource = useDraftResource(id);
+ return { resource, renderKey };
+ },
+ { initialProps: { renderKey: 0 }, wrapper: Wrapper },
+ );
+ expect(result.current.resource.promptDraft.text).toBe("Saved contents");
+ rerender({ renderKey: 1 });
+ rerender({ renderKey: 2 });
+ await act(async () => {});
+ expect(observerOptionUpdates).toBeGreaterThan(0);
+ expect(store.getSnapshot(id)).toBe(initialSnapshot);
+ expect(listener).not.toHaveBeenCalled();
+ act(() => {
+ queryClient.setQueryData(draftResourceQueryKey(id), {
+ ...saved,
+ revision: 2,
+ content: draftContentSchema.parse({
+ prompt: { text: "Changed remotely" },
+ }),
+ });
+ });
+ await waitFor(() =>
+ expect(result.current.resource.promptDraft.text).toBe("Changed remotely"),
+ );
+ expect(listener).toHaveBeenCalledTimes(1);
+ const changedSnapshot = store.getSnapshot(id);
+ act(() => {
+ queryClient.setQueryData(
+ draftResourceQueryKey(id),
+ (previous) => previous,
+ );
+ });
+ expect(store.getSnapshot(id)).toBe(changedSnapshot);
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(renders).toBeLessThan(10);
+ unsubscribe();
+ unsubscribeCache();
+ });
+});
+
+describe("draft HTTP gone responses", () => {
+ it("maps the shipped draft_gone GET response to a deleted resource", async () => {
+ stubErrorResponse("draft_gone");
+ await expect(draftResourceApi.get(id)).resolves.toBeNull();
+ const queryClient = createClient();
+ const store = new DraftResourceStore(
+ queryClient,
+ draftResourceApi,
+ browserDraftRecoveryStorage(),
+ 60_000,
+ );
+ stores.push(store);
+ queryClient.setQueryData(draftResourceQueryKey(id), saved);
+ store.edit(id, (value) => ({
+ ...value,
+ prompt: { ...value.prompt, text: "Unsaved recovery" },
+ }));
+ await expect(store.flush(id)).rejects.toMatchObject({
+ status: 410,
+ code: "draft_gone",
+ });
+ expect(store.getSnapshot(id).status).toBe("deleted");
+ expect(store.getSnapshot(id).content?.prompt.text).toBe("");
+ expect(store.getSnapshot(id).recoveryCopies[0]?.prompt.text).toBe(
+ "Unsaved recovery",
+ );
+ });
+
+ it("recognizes a gone draft during submit without retaining an unaccepted submission", async () => {
+ stubErrorResponse("draft_gone");
+ const queryClient = createClient();
+ queryClient.setQueryData(draftResourceQueryKey(id), saved);
+ const store = new DraftResourceStore(
+ queryClient,
+ draftResourceApi,
+ browserDraftRecoveryStorage(),
+ 60_000,
+ );
+ stores.push(store);
+ await expect(store.submit(id)).rejects.toMatchObject({
+ status: 410,
+ code: "draft_gone",
+ });
+ expect(store.getSnapshot(id).status).toBe("deleted");
+ expect(
+ readDraftRecoveries(browserDraftRecoveryStorage(), id).records[0]?.value
+ .submission,
+ ).toBeNull();
+ expect(store.getSnapshot(id).recoveryCopies[0]).toEqual(content);
+ });
+
+ it("retains the submitted revision when the receipt reports its thread was deleted", async () => {
+ const network = stubErrorResponse("draft_submitted_thread_gone");
+ await expect(draftResourceApi.get(id)).rejects.toMatchObject({
+ code: "draft_submitted_thread_gone",
+ });
+ const queryClient = createClient();
+ queryClient.setQueryData(draftResourceQueryKey(id), saved);
+ const store = new DraftResourceStore(
+ queryClient,
+ draftResourceApi,
+ browserDraftRecoveryStorage(),
+ 60_000,
+ );
+ stores.push(store);
+ await expect(store.submit(id)).rejects.toMatchObject({
+ status: 410,
+ code: "draft_submitted_thread_gone",
+ });
+ expect(store.getSnapshot(id).status).toBe("error");
+ expect(store.getSnapshot(id).content).toEqual(content);
+ const recovery = readDraftRecoveries(browserDraftRecoveryStorage(), id)
+ .records[0]?.value;
+ expect(recovery?.submission).toEqual({ revision: 1, content });
+ await expect(store.retry(id)).rejects.toMatchObject({
+ code: "draft_submitted_thread_gone",
+ });
+ expect(
+ network.mock.calls
+ .slice(1)
+ .every(
+ ([, init]) => JSON.parse(String(init?.body)).expectedRevision === 1,
+ ),
+ ).toBe(true);
+ });
+
+ it("clears an acknowledged legacy value after the server reports its imported draft is gone", async () => {
+ const rawValue = JSON.stringify(content.prompt);
+ const bytes = new TextEncoder().encode(rawValue);
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ const importedId = `drf_${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
+ localStorage.setItem(LEGACY_NEW_THREAD_DRAFT_KEY, rawValue);
+ localStorage.setItem(
+ `${LEGACY_DRAFT_IMPORT_PREFIX}${importedId}`,
+ JSON.stringify({
+ version: 1,
+ id: importedId,
+ rawValue,
+ content,
+ acknowledged: true,
+ }),
+ );
+ const network = stubErrorResponse("draft_gone");
+ const result = await importLegacyNewThreadDraft(
+ {},
+ {
+ api: draftResourceApi,
+ storage: browserDraftRecoveryStorage(),
+ queryClient: createClient(),
+ },
+ );
+ expect(result).toMatchObject({ id: importedId, draft: null, error: null });
+ expect(localStorage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY)).toBeNull();
+ expect(network).toHaveBeenCalledTimes(1);
+ expect(network.mock.calls[0]?.[1]?.method ?? "GET").toBe("GET");
+ });
+});
diff --git a/apps/app/src/hooks/useDraftResource.ts b/apps/app/src/hooks/useDraftResource.ts
new file mode 100644
index 00000000000..5ebaa4ea2f8
--- /dev/null
+++ b/apps/app/src/hooks/useDraftResource.ts
@@ -0,0 +1,136 @@
+import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ appendQuoteAndAttachmentsToDraft,
+ arePromptDraftStatesEqual,
+ emptyPromptDraftState,
+ isPromptDraftEmpty,
+ type PromptDraftState,
+} from "@bb/client-core";
+import type { DraftContent, DraftContentInput } from "@bb/server-contract";
+import type { usePromptDraftStorage } from "./usePromptDraftStorage";
+import {
+ draftResourceApi,
+ draftResourceQueryKey,
+} from "@/lib/drafts/resource-api";
+import { getDraftResourceStore } from "@/lib/drafts/resource-runtime";
+
+export {
+ createNewThreadDraft,
+ getDraftResourceStore,
+} from "@/lib/drafts/resource-runtime";
+
+const EMPTY_PROMPT = emptyPromptDraftState();
+
+export function useRecoverableDrafts() {
+ const queryClient = useQueryClient();
+ const store = getDraftResourceStore(queryClient);
+ const drafts = useSyncExternalStore(
+ store.subscribeRecoverable,
+ store.getRecoverableSnapshot,
+ store.getRecoverableSnapshot,
+ );
+ useEffect(() => {
+ store.resumeRecoveries();
+ }, [store]);
+ return drafts;
+}
+
+export function useDraftResource(id: string) {
+ const queryClient = useQueryClient();
+ const store = getDraftResourceStore(queryClient);
+ const subscribe = useCallback(
+ (listener: () => void) => store.subscribe(id, listener),
+ [id, store],
+ );
+ const getSnapshot = useCallback(() => store.getSnapshot(id), [id, store]);
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ const query = useQuery({
+ queryKey: draftResourceQueryKey(id),
+ queryFn: ({ signal }) => draftResourceApi.get(id, signal),
+ enabled: snapshot.status !== "deleted",
+ });
+ useEffect(() => {
+ store.resumeRecoveries();
+ }, [store]);
+
+ const prompt = snapshot.content?.prompt ?? EMPTY_PROMPT;
+ const promptDraft = useMemo>(() => {
+ const getCurrent = (): PromptDraftState =>
+ store.getSnapshot(id).content?.prompt ?? EMPTY_PROMPT;
+ const setDraft = (next: PromptDraftState): void => {
+ store.edit(id, (content) => ({ ...content, prompt: next }));
+ };
+ return {
+ storageKey: `draft-resource:${id}`,
+ getCurrent,
+ subscribe,
+ value: prompt.text,
+ text: prompt.text,
+ mentions: prompt.mentions,
+ attachments: prompt.attachments,
+ setDraft,
+ setTextAndMentions: (text, mentions) =>
+ setDraft({ ...getCurrent(), text, mentions }),
+ setAttachments: (attachments) =>
+ setDraft({ ...getCurrent(), attachments }),
+ addAttachment: (attachment) => {
+ const current = getCurrent();
+ if (current.attachments.some((item) => item.path === attachment.path))
+ return;
+ setDraft({
+ ...current,
+ attachments: [...current.attachments, attachment],
+ });
+ },
+ removeAttachment: (path) => {
+ const current = getCurrent();
+ setDraft({
+ ...current,
+ attachments: current.attachments.filter((item) => item.path !== path),
+ });
+ },
+ addQuote: (text, attachments = []) =>
+ setDraft(
+ appendQuoteAndAttachmentsToDraft(getCurrent(), text, attachments),
+ ),
+ clear: () => {
+ void store.delete(id).catch(() => {});
+ },
+ clearIfCurrentMatches: (expected) => {
+ if (!arePromptDraftStatesEqual(getCurrent(), expected)) return false;
+ void store.delete(id).catch(() => {});
+ return true;
+ },
+ restoreIfEmpty: (next) => {
+ if (!isPromptDraftEmpty(next) && isPromptDraftEmpty(getCurrent()))
+ setDraft(next);
+ },
+ };
+ }, [id, prompt, store, subscribe]);
+
+ const actions = useMemo(
+ () => ({
+ edit: (updater: (content: DraftContent) => DraftContentInput) =>
+ store.edit(id, updater),
+ flush: () => store.flush(id),
+ submit: () => store.submit(id),
+ delete: () => store.delete(id),
+ retry: () => store.retry(id),
+ reloadRemote: () => store.reloadRemote(id),
+ saveLocalAsCopy: (index?: number) => store.saveLocalAsCopy(id, index),
+ }),
+ [id, store],
+ );
+
+ return {
+ ...snapshot,
+ status:
+ query.isError && snapshot.status === "loading"
+ ? ("error" as const)
+ : snapshot.status,
+ error: snapshot.error ?? query.error,
+ promptDraft,
+ ...actions,
+ };
+}
diff --git a/apps/app/src/hooks/useForkThreadFromMessage.test.tsx b/apps/app/src/hooks/useForkThreadFromMessage.test.tsx
index 3a3927d6216..7fbe1869b86 100644
--- a/apps/app/src/hooks/useForkThreadFromMessage.test.tsx
+++ b/apps/app/src/hooks/useForkThreadFromMessage.test.tsx
@@ -9,10 +9,14 @@ import {
FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY,
type ForkThreadCreateSeed,
} from "@bb/client-core";
-import { getRootComposeRoutePath } from "@/lib/route-paths";
import { RouteNavigationProvider } from "@/components/ui/app-route-anchor";
import { useForkThreadFromMessage } from "./useForkThreadFromMessage";
+vi.mock("@/lib/drafts/resource-runtime", () => ({
+ createNewThreadDraft: () => "drf_fresh_navigation",
+ initializeNewThreadDraft: vi.fn(),
+}));
+
const mocks = vi.hoisted(() => ({
fetchQuery: vi.fn(),
navigate: vi.fn(),
@@ -52,6 +56,7 @@ vi.mock("@tanstack/react-query", async (importOriginal) => {
});
vi.mock("@/lib/root-compose-selection", () => ({
+ useRootComposeProjectId: () => ["proj_saved", vi.fn()],
useSetRootComposeProjectId: () => mocks.setRootComposeProjectId,
}));
@@ -101,12 +106,15 @@ describe("useForkThreadFromMessage", () => {
});
expect(mocks.setRootComposeProjectId).toHaveBeenCalledWith("proj_source");
- expect(mocks.navigate).toHaveBeenCalledWith(getRootComposeRoutePath(), {
- state: expect.objectContaining({
- focusPrompt: true,
- reuseEnvironmentId: "env_source",
- }),
- });
+ expect(mocks.navigate).toHaveBeenCalledWith(
+ "/?draft=drf_fresh_navigation",
+ {
+ state: expect.objectContaining({
+ focusPrompt: true,
+ reuseEnvironmentId: "env_source",
+ }),
+ },
+ );
const navigateState = mocks.navigate.mock.calls[0]?.[1]?.state as
| Record
diff --git a/apps/app/src/hooks/useForkThreadFromMessage.ts b/apps/app/src/hooks/useForkThreadFromMessage.ts
index 6c575387ef2..af41e26e761 100644
--- a/apps/app/src/hooks/useForkThreadFromMessage.ts
+++ b/apps/app/src/hooks/useForkThreadFromMessage.ts
@@ -1,3 +1,4 @@
+import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft";
import { useCallback, useLayoutEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Thread } from "@bb/domain";
@@ -7,12 +8,10 @@ import {
isThreadForkable,
type ForkThreadCreateSeed,
} from "@bb/client-core";
-import { getRootComposeRoutePath } from "@/lib/route-paths";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { threadDefaultExecutionOptionsQueryKey } from "@/hooks/queries/query-keys";
import { findCachedProviderInfo } from "@/hooks/queries/system-queries";
-import { useRouteNavigate } from "@/components/ui/app-route-anchor";
interface UseForkThreadFromMessageArgs {
sourceThread: Thread | null;
@@ -27,7 +26,7 @@ export function useForkThreadFromMessage({
}: UseForkThreadFromMessageArgs): (
target: ForkThreadFromMessageTarget,
) => Promise {
- const navigate = useRouteNavigate();
+ const openNewDraft = useOpenNewThreadDraft();
const queryClient = useQueryClient();
const setRootComposeProjectId = useSetRootComposeProjectId();
const forkInFlightRef = useRef(false);
@@ -78,17 +77,20 @@ export function useForkThreadFromMessage({
sourceThreadTitle: getThreadDisplayTitle(source),
};
setRootComposeProjectId(source.projectId);
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- reuseEnvironmentId: source.environmentId,
- [FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY]: seed,
+ openNewDraft(
+ { projectId: source.projectId },
+ {
+ state: {
+ focusPrompt: true,
+ reuseEnvironmentId: source.environmentId,
+ [FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY]: seed,
+ },
},
- });
+ );
} finally {
forkInFlightRef.current = false;
}
},
- [navigate, queryClient, setRootComposeProjectId],
+ [openNewDraft, queryClient, setRootComposeProjectId],
);
}
diff --git a/apps/app/src/hooks/useOpenNewThreadDraft.test.tsx b/apps/app/src/hooks/useOpenNewThreadDraft.test.tsx
new file mode 100644
index 00000000000..06cc0d11718
--- /dev/null
+++ b/apps/app/src/hooks/useOpenNewThreadDraft.test.tsx
@@ -0,0 +1,61 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { createStore, Provider } from "jotai";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { splitLayoutAtom } from "@/lib/split-layout/atoms";
+import { listPanes } from "@/lib/split-layout/ops";
+import { createSinglePaneLayout } from "@/views/thread-detail/splitThreadNavigation";
+import { useOpenNewThreadDraft } from "./useOpenNewThreadDraft";
+
+const mocks = vi.hoisted(() => ({ create: vi.fn(), navigate: vi.fn() }));
+vi.mock("@/lib/drafts/resource-runtime", () => ({
+ createNewThreadDraft: mocks.create,
+ initializeNewThreadDraft: () => true,
+}));
+vi.mock("@/components/ui/app-route-anchor", () => ({
+ useRouteNavigate: () => mocks.navigate,
+}));
+vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({
+ useIsCompactViewport: () => false,
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ window.sessionStorage.clear();
+ window.localStorage.clear();
+});
+
+describe("fresh New thread navigation", () => {
+ it("creates a distinct draft on each action in the focused pane and preserves each initial content", () => {
+ mocks.create
+ .mockReturnValueOnce("drf_first_action")
+ .mockReturnValueOnce("drf_second_action");
+ const store = createStore();
+ store.set(
+ splitLayoutAtom,
+ createSinglePaneLayout({ projectId: "p1", threadId: "t1" }),
+ );
+ const { result } = renderHook(() => useOpenNewThreadDraft(), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ });
+ const first = { projectId: "p1", prompt: { text: "First draft" } };
+ const second = { projectId: "p1", prompt: { text: "Second draft" } };
+ act(() => {
+ result.current(first, { state: { focusPrompt: true } });
+ result.current(second, { state: { focusPrompt: true } });
+ });
+ expect(mocks.create.mock.calls).toEqual([[first], [second]]);
+ expect(
+ listPanes(store.get(splitLayoutAtom)!.root).map((pane) => pane.content),
+ ).toEqual([{ kind: "new-thread", draftId: "drf_second_action" }]);
+ expect(mocks.navigate).toHaveBeenLastCalledWith(
+ "/?draft=drf_second_action",
+ { state: { focusPrompt: true } },
+ );
+ });
+});
diff --git a/apps/app/src/hooks/useOpenNewThreadDraft.ts b/apps/app/src/hooks/useOpenNewThreadDraft.ts
new file mode 100644
index 00000000000..9f07a950889
--- /dev/null
+++ b/apps/app/src/hooks/useOpenNewThreadDraft.ts
@@ -0,0 +1,31 @@
+import { useCallback } from "react";
+import { useStore } from "jotai";
+import type { NavigateOptions } from "react-router-dom";
+import type { DraftContentInput } from "@bb/server-contract";
+import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
+import { useRouteNavigate } from "@/components/ui/app-route-anchor";
+import { createNewThreadDraft } from "@/lib/drafts/resource-runtime";
+import { useRootComposeProjectId } from "@/lib/root-compose-selection";
+import { openDraftInSplit } from "@/lib/split-layout/openDraftInSplit";
+
+export function useOpenNewThreadDraft() {
+ const store = useStore();
+ const navigate = useRouteNavigate();
+ const isCompact = useIsCompactViewport();
+ const [projectId] = useRootComposeProjectId();
+ return useCallback(
+ (content: DraftContentInput = {}, options?: NavigateOptions) => {
+ const draftId = createNewThreadDraft({ projectId, ...content });
+ openDraftInSplit({
+ store,
+ navigate: (route, splitOptions) =>
+ navigate(route, { ...splitOptions, ...options }),
+ draftId,
+ split: "replace",
+ isCompact,
+ });
+ return draftId;
+ },
+ [isCompact, navigate, projectId, store],
+ );
+}
diff --git a/apps/app/src/hooks/useQuickCreateProject.test.tsx b/apps/app/src/hooks/useQuickCreateProject.test.tsx
index 0ce3c035c7f..40e21e77323 100644
--- a/apps/app/src/hooks/useQuickCreateProject.test.tsx
+++ b/apps/app/src/hooks/useQuickCreateProject.test.tsx
@@ -5,6 +5,7 @@ import type { Host } from "@bb/domain";
import { makeHost } from "@bb/test-helpers/domain-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useQuickCreateProject } from "./useQuickCreateProject";
+import type { LocalPathSubmitParams } from "./useLocalPathPicker";
const mocks = vi.hoisted(() => ({
hosts: [] as Host[] | undefined,
@@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => ({
openPathEntry: vi.fn(),
openPicker: vi.fn(),
setRootComposeProjectId: vi.fn(),
+ submit: null as ((params: LocalPathSubmitParams) => void) | null,
}));
vi.mock("react-router-dom", () => ({
@@ -34,22 +36,29 @@ vi.mock("@/hooks/queries/host-queries", async (importOriginal) => ({
}));
vi.mock("@/hooks/useLocalPathPicker", () => ({
- useLocalPathPicker: () => ({
- isAvailable: true,
- hostId: "host_atum",
- hostName: "atum",
- openPathEntry: mocks.openPathEntry,
- openPicker: mocks.openPicker,
- platform: "linux",
- projectPathDialog: {
- isOpen: false,
- onClose: mocks.onClose,
- onOpen: mocks.onOpen,
- onOpenChange: mocks.onOpenChange,
- target: null,
- },
- submitProjectPath: vi.fn(),
- }),
+ useLocalPathPicker: ({
+ submit,
+ }: {
+ submit: (params: LocalPathSubmitParams) => void;
+ }) => {
+ mocks.submit = submit;
+ return {
+ isAvailable: true,
+ hostId: "host_atum",
+ hostName: "atum",
+ openPathEntry: mocks.openPathEntry,
+ openPicker: mocks.openPicker,
+ platform: "linux",
+ projectPathDialog: {
+ isOpen: false,
+ onClose: mocks.onClose,
+ onOpen: mocks.onOpen,
+ onOpenChange: mocks.onOpenChange,
+ target: null,
+ },
+ submitProjectPath: vi.fn(),
+ };
+ },
}));
vi.mock("@/lib/root-compose-selection", () => ({
@@ -105,4 +114,39 @@ describe("useQuickCreateProject", () => {
"host_sandbox",
]);
});
+
+ it("completes project selection without navigating away from the originating draft", async () => {
+ const selected = vi.fn();
+ const { result } = renderHook(() => useQuickCreateProject());
+ act(() => result.current.openCreateDialogForSelection(selected));
+ act(() =>
+ mocks.submit?.({
+ path: "/work/new-project",
+ hostId: "host_atum",
+ target: { kind: "create" },
+ closeDialog: mocks.onClose,
+ }),
+ );
+ const options = mocks.mutate.mock.calls[0][1];
+ act(() => result.current.openCreateDialog());
+ await act(async () => options.onSuccess({ id: "proj_created" }));
+ expect(selected).toHaveBeenCalledWith("proj_created");
+ expect(mocks.onClose).toHaveBeenCalled();
+ expect(mocks.navigate).not.toHaveBeenCalled();
+ expect(mocks.setRootComposeProjectId).not.toHaveBeenCalled();
+ act(() =>
+ mocks.submit?.({
+ path: "/work/sidebar-project",
+ hostId: "host_atum",
+ target: { kind: "create" },
+ closeDialog: mocks.onClose,
+ }),
+ );
+ await act(async () =>
+ mocks.mutate.mock.calls[1][1].onSuccess({ id: "proj_sidebar" }),
+ );
+ expect(selected).toHaveBeenCalledTimes(1);
+ expect(mocks.setRootComposeProjectId).toHaveBeenCalledWith("proj_sidebar");
+ expect(mocks.navigate).toHaveBeenCalledWith("/", { replace: true });
+ });
});
diff --git a/apps/app/src/hooks/useQuickCreateProject.tsx b/apps/app/src/hooks/useQuickCreateProject.tsx
index 43d79bbf9b4..bf69d58eae5 100644
--- a/apps/app/src/hooks/useQuickCreateProject.tsx
+++ b/apps/app/src/hooks/useQuickCreateProject.tsx
@@ -3,6 +3,7 @@ import {
useCallback,
useContext,
useMemo,
+ useRef,
type ReactNode,
} from "react";
import { useLocation, useNavigate } from "react-router-dom";
@@ -34,6 +35,9 @@ interface QuickCreateProjectController {
isAvailable: boolean;
isCreating: boolean;
openCreateDialog: () => void;
+ openCreateDialogForSelection: (
+ onCreated: (projectId: string) => void | Promise,
+ ) => void;
platform: HostPlatform | null;
hostId: string | null;
hostName: string | null;
@@ -56,12 +60,16 @@ export function useQuickCreateProject(): QuickCreateProjectController {
const location = useLocation();
const setRootComposeProjectId = useSetRootComposeProjectId();
const shouldReplaceRoute = location.pathname === APP_ROOT_ROUTE_PATH;
+ const selectionCompletion = useRef<
+ ((projectId: string) => void | Promise) | null
+ >(null);
const submit = useCallback(
({ path, hostId, target, closeDialog }: LocalPathSubmitParams) => {
if (target.kind !== "create") return;
const name = deriveProjectNameFromPath(path).trim();
if (!name) return;
+ const onCreated = selectionCompletion.current;
mutate(
{
@@ -69,8 +77,12 @@ export function useQuickCreateProject(): QuickCreateProjectController {
source: { type: "local_path", hostId, path },
},
{
- onSuccess: (project) => {
+ onSuccess: async (project) => {
closeDialog();
+ if (onCreated) {
+ await onCreated(project.id);
+ return;
+ }
setRootComposeProjectId(project.id);
void navigate(getRootComposeRoutePath(), {
replace: shouldReplaceRoute,
@@ -88,14 +100,23 @@ export function useQuickCreateProject(): QuickCreateProjectController {
});
const openCreateDialog = useCallback(() => {
+ selectionCompletion.current = null;
controller.openPathEntry({ kind: "create" });
}, [controller]);
+ const openCreateDialogForSelection = useCallback(
+ (onCreated: (projectId: string) => void | Promise) => {
+ selectionCompletion.current = onCreated;
+ controller.openPathEntry({ kind: "create" });
+ },
+ [controller],
+ );
return useMemo(
() => ({
isAvailable: controller.isAvailable,
isCreating: isPending,
openCreateDialog,
+ openCreateDialogForSelection,
platform: controller.platform,
hostId: controller.hostId,
hostName: controller.hostName,
@@ -103,7 +124,13 @@ export function useQuickCreateProject(): QuickCreateProjectController {
projectPathDialog: controller.projectPathDialog,
submitProjectPath: controller.submitProjectPath,
}),
- [controller, hosts, isPending, openCreateDialog],
+ [
+ controller,
+ hosts,
+ isPending,
+ openCreateDialog,
+ openCreateDialogForSelection,
+ ],
);
}
diff --git a/apps/app/src/hooks/useThreadCreationOptions.test.tsx b/apps/app/src/hooks/useThreadCreationOptions.test.tsx
index d59aae5129d..905a8a0a4b1 100644
--- a/apps/app/src/hooks/useThreadCreationOptions.test.tsx
+++ b/apps/app/src/hooks/useThreadCreationOptions.test.tsx
@@ -10,7 +10,11 @@ import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import type { ProviderModelCatalogScope } from "@bb/domain";
import type { QueryClient } from "@tanstack/react-query";
-import { hostsQueryKey, systemProvidersQueryKey } from "./queries/query-keys";
+import {
+ hostsQueryKey,
+ systemExecutionOptionsQueryKey,
+ systemProvidersQueryKey,
+} from "./queries/query-keys";
import { getProjectScopedStorageKey } from "@/lib/project-scoped-storage";
import { useThreadCreationOptions } from "./useThreadCreationOptions";
import {
@@ -294,6 +298,188 @@ afterEach(() => {
});
describe("useThreadCreationOptions", () => {
+ it("retains a draft's unavailable selections instead of applying other defaults", async () => {
+ const { result } = renderHook(
+ () =>
+ useThreadCreationOptions({
+ scope: "component-local",
+ resetKey: "draft-unavailable",
+ preserveUnavailableSelections: true,
+ preferReadyProviderWhenUnset: true,
+ initialProviderId: "removed-provider",
+ initialModel: "removed-model",
+ initialReasoningLevel: "xhigh",
+ initialPermissionMode: "full",
+ initialServiceTier: "fast",
+ }),
+ { wrapper: createQueryClientTestHarness().wrapper },
+ );
+ await waitFor(() =>
+ expect(result.current.modelCatalogIsVerified).toBe(true),
+ );
+ expect(result.current.selectedProviderId).toBe("removed-provider");
+ expect(result.current.selectedModel).toBe("removed-model");
+ expect(result.current.activeModel).toBeUndefined();
+ expect(result.current.reasoningLevel).toBe("xhigh");
+ expect(result.current.serviceTier).toBe("fast");
+ expect(sdk.system.providerStates).not.toHaveBeenCalled();
+ act(() =>
+ result.current.setProviderModelReasoning({
+ providerId: GLOBAL_PROVIDER_ID,
+ model: "global-model",
+ reasoningLevel: "high",
+ }),
+ );
+ expect(result.current.selectedProviderId).toBe(GLOBAL_PROVIDER_ID);
+ expect(result.current.selectedModel).toBe("global-model");
+ });
+
+ it("keeps the normalized reasoning effort when a draft explicitly selects a shorter model ladder", async () => {
+ const response = executionOptionsResponse();
+ vi.mocked(sdk.system.executionOptions).mockResolvedValue({
+ ...response,
+ models: response.models.map((model) =>
+ model.model === "global-model"
+ ? {
+ ...model,
+ supportedReasoningEfforts: [
+ ...model.supportedReasoningEfforts,
+ { reasoningEffort: "max", description: "" },
+ ],
+ }
+ : model,
+ ),
+ });
+ const { result } = renderHook(
+ () =>
+ useThreadCreationOptions({
+ scope: "component-local",
+ resetKey: "draft-model-change",
+ preserveUnavailableSelections: true,
+ initialProviderId: GLOBAL_PROVIDER_ID,
+ initialModel: "global-model",
+ initialReasoningLevel: "max",
+ }),
+ { wrapper: createQueryClientTestHarness().wrapper },
+ );
+ await waitFor(() =>
+ expect(result.current.modelCatalogIsVerified).toBe(true),
+ );
+ expect(result.current.reasoningLevel).toBe("max");
+
+ act(() => result.current.setSelectedModel("project-model"));
+
+ expect(result.current.selectedModel).toBe("project-model");
+ expect(result.current.reasoningLevel).toBe("high");
+ expect(result.current.executionInputSources.reasoningLevel).toBe(
+ "explicit",
+ );
+ });
+
+ it("waits for the ready provider before exposing fresh draft defaults and keeps it after a machine change", async () => {
+ let resolveProviderStates: (
+ value: SystemProviderStatesResponse,
+ ) => void = () => {};
+ vi.mocked(sdk.system.providerStates).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveProviderStates = resolve;
+ }),
+ );
+ vi.mocked(sdk.system.executionOptions).mockImplementation(async (args) =>
+ providerExecutionOptionsResponse(args?.providerId),
+ );
+ const { wrapper, queryClient } = createQueryClientTestHarness();
+ queryClient.setQueryData(
+ systemExecutionOptionsQueryKey({
+ environmentId: null,
+ hostId: "remote-host",
+ providerId: null,
+ }),
+ providerExecutionOptionsResponse(undefined),
+ );
+ const { result } = renderHook(
+ () =>
+ useThreadCreationOptions({
+ scope: "component-local",
+ resetKey: "draft-ready-provider",
+ preserveUnavailableSelections: true,
+ preferReadyProviderWhenUnset: true,
+ initialEnvironmentSelectionValue: "provider:project-checkout",
+ resolveProviderRouting: (value) => ({
+ hostId:
+ value === "provider:project-checkout"
+ ? "remote-host"
+ : "second-host",
+ }),
+ }),
+ { wrapper },
+ );
+ await waitFor(() =>
+ expect(sdk.system.providerStates).toHaveBeenCalledWith({
+ environmentId: undefined,
+ hostId: "remote-host",
+ signal: expect.any(AbortSignal),
+ }),
+ );
+ expect(result.current.selectedProviderId).toBe("");
+ expect(result.current.selectedModel).toBe("");
+ expect(result.current.isLoadingModels).toBe(true);
+
+ await act(async () => {
+ resolveProviderStates(readyProviderStates(PROJECT_PROVIDER_ID));
+ });
+ await waitFor(() => {
+ expect(result.current.selectedProviderId).toBe(PROJECT_PROVIDER_ID);
+ expect(result.current.selectedModel).toBe("project-default");
+ expect(result.current.isLoadingModels).toBe(false);
+ });
+
+ act(() =>
+ result.current.setEnvironmentSelectionValue("provider:git-worktree"),
+ );
+ await waitFor(() =>
+ expect(sdk.system.executionOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ hostId: "second-host",
+ providerId: PROJECT_PROVIDER_ID,
+ }),
+ ),
+ );
+ expect(result.current.selectedProviderId).toBe(PROJECT_PROVIDER_ID);
+ expect(sdk.system.providerStates).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps a draft's saved permission choice visible when the machine ceiling changes", async () => {
+ vi.mocked(sdk.system.executionOptions).mockResolvedValue({
+ ...executionOptionsResponse(),
+ permissionCeiling: "auto",
+ });
+ const { result } = renderHook(
+ () =>
+ useThreadCreationOptions({
+ scope: "component-local",
+ resetKey: "draft-permissions",
+ preserveUnavailableSelections: true,
+ initialProviderId: GLOBAL_PROVIDER_ID,
+ initialModel: "global-model",
+ initialPermissionMode: "full",
+ }),
+ { wrapper: createQueryClientTestHarness().wrapper },
+ );
+ await waitFor(() =>
+ expect(result.current.permissionModeIsVerified).toBe(true),
+ );
+ expect(result.current.permissionMode).toBe("full");
+ expect(
+ result.current.permissionModeOptions.find(
+ (option) => option.value === "full",
+ )?.disabled,
+ ).toBe(true);
+ act(() => result.current.setPermissionMode("auto"));
+ expect(result.current.permissionMode).toBe("auto");
+ });
+
it("keeps the selected remembered provider branded while models load", () => {
window.localStorage.setItem("bb.promptbox.provider", "codex");
writeCachedProviderList(
diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts
index 7a3bbec584c..89b76c81fb2 100644
--- a/apps/app/src/hooks/useThreadCreationOptions.ts
+++ b/apps/app/src/hooks/useThreadCreationOptions.ts
@@ -227,6 +227,7 @@ export function useThreadCreationOptions(
initialReasoningLevel,
initialServiceTier,
preferReadyProviderWhenUnset = false,
+ preserveUnavailableSelections = false,
preferenceProjectId,
resolveProviderRouting,
resetKey,
@@ -340,7 +341,7 @@ export function useThreadCreationOptions(
});
const canResolveReadyProvider =
executionOptionsQueryEnabled &&
- scope === "new-thread" &&
+ (scope === "new-thread" || preserveUnavailableSelections) &&
preferReadyProviderWhenUnset &&
selectedProviderIdBeforeReadyFallback.length === 0;
const shouldResolveReadyProvider =
@@ -378,38 +379,47 @@ export function useThreadCreationOptions(
]);
const rawSelectedProviderId =
selectedProviderIdBeforeReadyFallback || readyProviderId || "";
+ const isResolvingReadyProvider =
+ shouldResolveReadyProvider && providerStatesQuery.isPending;
const executionOptionsProviderId = executionOptionsQueryEnabled
? rawSelectedProviderId || undefined
: undefined;
const executionOptionsQuery = useSystemExecutionOptions({
- enabled: executionOptionsQueryEnabled,
+ enabled: executionOptionsQueryEnabled && !isResolvingReadyProvider,
...executionOptionsRouting,
providerId: executionOptionsProviderId,
});
const hostsQuery = useHosts();
const systemConfig = useSystemConfig();
- const providers = executionOptionsQuery.data?.providers ?? EMPTY_PROVIDERS;
+ const executionOptionsData = isResolvingReadyProvider
+ ? undefined
+ : executionOptionsQuery.data;
+ const providers = executionOptionsData?.providers ?? EMPTY_PROVIDERS;
const isLoadingModels =
executionOptionsQueryEnabled &&
- (executionOptionsQuery.isLoading ||
+ (isResolvingReadyProvider ||
+ executionOptionsQuery.isLoading ||
(executionOptionsQuery.isPlaceholderData &&
- (executionOptionsQuery.data?.models.length ?? 0) === 0));
+ (executionOptionsData?.models.length ?? 0) === 0));
const modelLoadError =
- executionOptionsQuery.data?.modelLoadError ?? NO_MODEL_LOAD_ERROR;
+ executionOptionsData?.modelLoadError ?? NO_MODEL_LOAD_ERROR;
const modelLoadFailed =
executionOptionsQuery.isError || modelLoadError !== null;
const modelCatalogIsVerified =
- executionOptionsQuery.data !== undefined &&
+ executionOptionsData !== undefined &&
!executionOptionsQuery.isPlaceholderData &&
!executionOptionsQuery.isError &&
modelLoadError === null;
const permissionModeIsVerified =
- executionOptionsQuery.data !== undefined &&
+ executionOptionsData !== undefined &&
!executionOptionsQuery.isPlaceholderData &&
!executionOptionsQuery.isError;
const hasMultipleProviders = providers.length >= 2;
const effectiveProviderId = useMemo(() => {
+ if (preserveUnavailableSelections && rawSelectedProviderId) {
+ return rawSelectedProviderId;
+ }
if (
rawSelectedProviderId &&
providers.some((provider) => provider.id === rawSelectedProviderId)
@@ -417,7 +427,7 @@ export function useThreadCreationOptions(
return rawSelectedProviderId;
}
return providers[0]?.id ?? "";
- }, [providers, rawSelectedProviderId]);
+ }, [preserveUnavailableSelections, providers, rawSelectedProviderId]);
const { setValue: setStoredSelectedModel, value: storedSelectedModel } =
usePromptBoxModelPreference(effectiveProviderId);
@@ -494,7 +504,7 @@ export function useThreadCreationOptions(
]);
const routedCeiling = executionOptionsQuery.isPlaceholderData
? undefined
- : executionOptionsQuery.data?.permissionCeiling;
+ : executionOptionsData?.permissionCeiling;
const permissionCeiling: PermissionMode =
routedCeiling ?? routedHostCeiling ?? "full";
const allowedPermissionModes = useMemo(
@@ -542,36 +552,42 @@ export function useThreadCreationOptions(
} = useMemo(
() =>
resolveModelCatalogSelection({
- models: executionOptionsQuery.data?.models ?? [],
- selectedOnlyModels:
- executionOptionsQuery.data?.selectedOnlyModels ?? [],
+ models: executionOptionsData?.models ?? [],
+ selectedOnlyModels: executionOptionsData?.selectedOnlyModels ?? [],
selectedModel: rawSelectedModel,
preferredReasoningLevel,
provider: selectedProviderInfo,
catalogIsVerified: modelCatalogIsVerified,
+ preserveUnavailableSelection: preserveUnavailableSelections,
formatModelLabel,
}),
[
- executionOptionsQuery.data?.models,
- executionOptionsQuery.data?.selectedOnlyModels,
+ executionOptionsData?.models,
+ executionOptionsData?.selectedOnlyModels,
modelCatalogIsVerified,
preferredReasoningLevel,
+ preserveUnavailableSelections,
rawSelectedModel,
selectedProviderInfo,
],
);
const serviceTier = useMemo(
- () => (supportsServiceTier ? rawServiceTier : undefined),
- [rawServiceTier, supportsServiceTier],
+ () =>
+ supportsServiceTier || preserveUnavailableSelections
+ ? rawServiceTier
+ : undefined,
+ [preserveUnavailableSelections, rawServiceTier, supportsServiceTier],
);
- const permissionMode = resolvePermissionModeSelection({
- rawPermissionMode,
- permissionModes:
- allowedPermissionModes.length > 0
- ? allowedPermissionModes
- : permissionModes,
- });
+ const permissionMode = preserveUnavailableSelections
+ ? rawPermissionMode
+ : resolvePermissionModeSelection({
+ rawPermissionMode,
+ permissionModes:
+ allowedPermissionModes.length > 0
+ ? allowedPermissionModes
+ : permissionModes,
+ });
const environmentSelectionValue = rawEnvironmentSelectionValue;
const touchedFieldsPendingReset =
usesLocalThreadSelections && threadResetKeyRef.current !== resetKey;
@@ -758,10 +774,8 @@ export function useThreadCreationOptions(
(value: string) => {
touchedThreadFieldsRef.current.add("selectedModel");
const nextModel =
- executionOptionsQuery.data?.models.find(
- (model) => model.model === value,
- ) ??
- executionOptionsQuery.data?.selectedOnlyModels.find(
+ executionOptionsData?.models.find((model) => model.model === value) ??
+ executionOptionsData?.selectedOnlyModels.find(
(model) => model.model === value,
);
const nextReasoningLevel = resolveModelReasoningLevel(
@@ -776,6 +790,7 @@ export function useThreadCreationOptions(
});
return;
}
+ touchedThreadFieldsRef.current.add("reasoningLevel");
setLocalProvidersUsingDefaults((current) => {
if (!current.has(effectiveProviderId)) return current;
const next = new Set(current);
@@ -794,8 +809,8 @@ export function useThreadCreationOptions(
},
[
effectiveProviderId,
- executionOptionsQuery.data?.models,
- executionOptionsQuery.data?.selectedOnlyModels,
+ executionOptionsData?.models,
+ executionOptionsData?.selectedOnlyModels,
reasoningLevel,
setStoredProviderModelReasoning,
usesStoredCreateSelections,
diff --git a/apps/app/src/lib/app-route-history.test.tsx b/apps/app/src/lib/app-route-history.test.tsx
index 8a8c433556e..d6634d106a8 100644
--- a/apps/app/src/lib/app-route-history.test.tsx
+++ b/apps/app/src/lib/app-route-history.test.tsx
@@ -8,8 +8,10 @@ import {
screen,
waitFor,
} from "@testing-library/react";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
+import { RouteNavigationProvider } from "@/components/ui/app-route-anchor";
+import { parseDraftRouteId } from "./draft-route";
import { PluginContext } from "@/components/plugin/plugin-context";
import { SidebarHistoryNavigationControls } from "@/components/sidebar/SidebarHistoryNavigationControls";
import { useBbNavigate } from "./plugin-sdk-hooks";
@@ -27,6 +29,11 @@ import {
useRouteStateHistoryNavigation,
} from "./app-route-history";
+vi.mock("@/lib/drafts/resource-runtime", async (importOriginal) => ({
+ ...(await importOriginal()),
+ createNewThreadDraft: vi.fn(() => `drf_${crypto.randomUUID()}`),
+}));
+
const TOOL_SKILL_DETAIL_ROUTE = getSkillDetailRoutePath({
skillId: "skill_review_loop",
});
@@ -113,7 +120,12 @@ function PluginNavigationHarness() {
return (
-
{location.pathname}
+
+ {location.pathname}
+
@@ -172,9 +184,11 @@ function RemountablePluginNavigationHarness() {
-
-
-
+
+
+
+
+
>
);
}
@@ -202,6 +216,8 @@ describe("useRouteStateHistoryNavigation", () => {
afterEach(() => {
cleanup();
resetAppRouteHistoryForTest();
+ window.localStorage.clear();
+ window.sessionStorage.clear();
});
it("keeps the stack when the controls remount across sidebar layouts", async () => {
@@ -296,12 +312,16 @@ describe("useRouteStateHistoryNavigation", () => {
await clickAndExpectPath("Edit from detail", editPath);
await clickAndExpectPath("Remount plugin", editPath);
await clickAndExpectPath("Redirect edit to compose", "/");
+ const firstDraftId = screen.getByTestId("path").dataset.draftId;
+ expect(firstDraftId).toBeTruthy();
await clickAndExpectPath("Native back", detailPath);
await clickAndExpectPath("Native back", getAutomationsRoutePath());
await clickAndExpectPath("Open direct edit", editPath);
await clickAndExpectPath("Remount plugin", editPath);
await clickAndExpectPath("Redirect edit to compose", "/");
+ expect(screen.getByTestId("path").dataset.draftId).toBeTruthy();
+ expect(screen.getByTestId("path").dataset.draftId).not.toBe(firstDraftId);
await clickAndExpectPath("Native back", getAutomationsRoutePath());
});
diff --git a/apps/app/src/lib/draft-route.test.ts b/apps/app/src/lib/draft-route.test.ts
new file mode 100644
index 00000000000..cc08965796d
--- /dev/null
+++ b/apps/app/src/lib/draft-route.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { getDraftRoutePath, parseDraftRouteId } from "./draft-route";
+import { paneContentForPathname } from "@/views/thread-detail/splitThreadNavigation";
+
+describe("draft routes", () => {
+ it("round trips a stable draft alongside other query parameters", () => {
+ const id = "drf_c4f849da-569e-4822-bb8d-b143438b20b7";
+ expect(getDraftRoutePath(id)).toBe(`/?draft=${id}`);
+ expect(parseDraftRouteId(`?draft=${id}&initialPrompt=hello`)).toBe(id);
+ expect(paneContentForPathname(`/?draft=${id}#prompt`)).toEqual({
+ kind: "new-thread",
+ draftId: id,
+ });
+ });
+ it.each(["", "?draft=", "?draft=thread_1", "?draft=drf_bad/path"])(
+ "rejects invalid draft query %s",
+ (search) => {
+ expect(parseDraftRouteId(search)).toBeNull();
+ expect(paneContentForPathname(`/${search}`)).toBeNull();
+ },
+ );
+});
diff --git a/apps/app/src/lib/draft-route.ts b/apps/app/src/lib/draft-route.ts
new file mode 100644
index 00000000000..aa70a3cce97
--- /dev/null
+++ b/apps/app/src/lib/draft-route.ts
@@ -0,0 +1,12 @@
+import { draftIdSchema } from "@bb/server-contract";
+
+export function getDraftRoutePath(draftId: string): string {
+ return `/?draft=${encodeURIComponent(draftId)}`;
+}
+
+export function parseDraftRouteId(search: string): string | null {
+ const result = draftIdSchema.safeParse(
+ new URLSearchParams(search).get("draft"),
+ );
+ return result.success ? result.data : null;
+}
diff --git a/apps/app/src/lib/drafts/draft-list.test.ts b/apps/app/src/lib/drafts/draft-list.test.ts
new file mode 100644
index 00000000000..a934a780484
--- /dev/null
+++ b/apps/app/src/lib/drafts/draft-list.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { draftContentSchema, type Draft } from "@bb/server-contract";
+import type { RecoverableDraftSnapshot } from "./resource-store";
+import { getDraftDisplayTitle, mergeDraftListEntries } from "./draft-list";
+
+function draft(id: string, text: string, updatedAt: number): Draft {
+ return {
+ id,
+ content: draftContentSchema.parse({ prompt: { text } }),
+ revision: 1,
+ createdAt: 1,
+ updatedAt,
+ };
+}
+
+function recovered(remote: Draft): RecoverableDraftSnapshot {
+ return {
+ id: remote.id,
+ content: remote.content,
+ updatedAt: remote.updatedAt,
+ status: "error",
+ error: new Error("Offline"),
+ persistenceError: null,
+ };
+}
+
+describe("draft discovery", () => {
+ it("keeps a closed unsaved draft visible and gives its newer local content precedence", () => {
+ const remote = draft("drf_existingdraft", "Saved text", 3);
+ const pending = recovered(draft(remote.id, "Unsaved edits", 8));
+ const missingProject = recovered(
+ draft("drf_missingproject", "Keep this project", 10),
+ );
+ missingProject.content.projectId = "proj_deleted";
+ const result = mergeDraftListEntries(
+ [remote, draft("drf_olderdraft", "Older", 1)],
+ [pending, missingProject],
+ );
+ expect(result.map((entry) => entry.id)).toEqual([
+ missingProject.id,
+ remote.id,
+ "drf_olderdraft",
+ ]);
+ expect(result[0]?.content.projectId).toBe("proj_deleted");
+ expect(result[1]?.content.prompt.text).toBe("Unsaved edits");
+ expect(result[1]?.recoveryStatus).toBe("error");
+ });
+
+ it("excludes option-only blank drafts but keeps whitespace and attachments", () => {
+ const blank = draft("drf_blankdraft", "", 9);
+ blank.content.options.model = "model-selection";
+ const whitespace = draft("drf_whitespacedraft", " ", 8);
+ const attachment = draft("drf_attachmentdraft", "", 7);
+ attachment.content.prompt.attachments = [
+ {
+ type: "localFile",
+ path: "uploads/notes.txt",
+ name: "notes.txt",
+ sizeBytes: 100,
+ },
+ ];
+ const result = mergeDraftListEntries([blank, whitespace, attachment], []);
+ expect(result.map((entry) => entry.id)).toEqual([
+ whitespace.id,
+ attachment.id,
+ ]);
+ expect(getDraftDisplayTitle(attachment)).toBe("notes.txt");
+ expect(getDraftDisplayTitle(whitespace)).toBe("Untitled draft");
+ });
+
+ it("preserves recovery for a deleted server identity without allocating a replacement", () => {
+ const local = recovered(draft("drf_deleteddraft", "Recover this", 5));
+ local.status = "deleted";
+ expect(mergeDraftListEntries([], [local])).toMatchObject([
+ { id: local.id, recoveryStatus: "deleted" },
+ ]);
+ });
+});
diff --git a/apps/app/src/lib/drafts/draft-list.ts b/apps/app/src/lib/drafts/draft-list.ts
new file mode 100644
index 00000000000..39646b7e3fc
--- /dev/null
+++ b/apps/app/src/lib/drafts/draft-list.ts
@@ -0,0 +1,39 @@
+import type { Draft } from "@bb/server-contract";
+import type { RecoverableDraftSnapshot } from "./resource-store";
+
+type DraftListContent = Pick
;
+
+export interface DraftListEntry extends DraftListContent {
+ recoveryStatus: RecoverableDraftSnapshot["status"] | null;
+}
+
+export function mergeDraftListEntries(
+ remote: readonly DraftListContent[],
+ local: readonly RecoverableDraftSnapshot[],
+): DraftListEntry[] {
+ const entries = new Map();
+ for (const draft of remote) {
+ entries.set(draft.id, { ...draft, recoveryStatus: null });
+ }
+ for (const draft of local) {
+ entries.set(draft.id, { ...draft, recoveryStatus: draft.status });
+ }
+ return [...entries.values()]
+ .filter(
+ (draft) =>
+ draft.content.prompt.text.length > 0 ||
+ draft.content.prompt.attachments.length > 0,
+ )
+ .sort(
+ (left, right) =>
+ right.updatedAt - left.updatedAt || left.id.localeCompare(right.id),
+ );
+}
+
+export function getDraftDisplayTitle(draft: DraftListContent): string {
+ return (
+ draft.content.prompt.text.trim() ||
+ draft.content.prompt.attachments[0]?.name ||
+ "Untitled draft"
+ );
+}
diff --git a/apps/app/src/lib/drafts/legacy-import.ts b/apps/app/src/lib/drafts/legacy-import.ts
new file mode 100644
index 00000000000..d3c2c80d605
--- /dev/null
+++ b/apps/app/src/lib/drafts/legacy-import.ts
@@ -0,0 +1,123 @@
+import { isPromptDraftEmpty, parsePromptDraftStorage } from "@bb/client-core";
+import {
+ draftContentSchema,
+ draftIdSchema,
+ draftPromptSchema,
+ type Draft,
+ type DraftContentInput,
+} from "@bb/server-contract";
+import { z } from "zod";
+import type { QueryClient } from "@tanstack/react-query";
+import {
+ cacheDraftResource,
+ invalidateDraftResources,
+} from "@/hooks/cache-owners/draft-cache-owner";
+import { appQueryClient } from "../app-query-client";
+import { draftResourceApi, type DraftResourceApi } from "./resource-api";
+import {
+ browserDraftRecoveryStorage,
+ type DraftRecoveryStorage,
+} from "./recovery";
+
+export const LEGACY_NEW_THREAD_DRAFT_KEY = "bb.promptbox.contents-draft-3";
+export const LEGACY_DRAFT_IMPORT_PREFIX = "bb.draft-import.v1:";
+
+const importRecordSchema = z.object({
+ version: z.literal(1),
+ id: draftIdSchema,
+ rawValue: z.string(),
+ content: draftContentSchema,
+ acknowledged: z.boolean(),
+});
+
+interface LegacyDraftImportDependencies {
+ api: Pick;
+ storage: DraftRecoveryStorage;
+ queryClient: QueryClient;
+}
+
+export interface LegacyDraftImportResult {
+ id: string | null;
+ draft: Draft | null;
+ newerLegacyValue: boolean;
+ error: Error | null;
+}
+
+async function importId(rawValue: string): Promise {
+ const bytes = new TextEncoder().encode(rawValue);
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ return `drf_${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
+}
+
+export async function importLegacyNewThreadDraft(
+ seed: Omit,
+ dependencies: LegacyDraftImportDependencies = {
+ api: draftResourceApi,
+ storage: browserDraftRecoveryStorage(),
+ queryClient: appQueryClient,
+ },
+): Promise {
+ const { api, storage, queryClient } = dependencies;
+ const result: LegacyDraftImportResult = {
+ id: null,
+ draft: null,
+ newerLegacyValue: false,
+ error: null,
+ };
+ try {
+ const rawValue = storage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY);
+ if (rawValue === null) return result;
+ const parsed: unknown = JSON.parse(rawValue);
+ draftPromptSchema.parse(parsed);
+ const prompt = parsePromptDraftStorage(rawValue);
+ if (isPromptDraftEmpty(prompt)) return result;
+ const id = await importId(rawValue);
+ result.id = id;
+ const key = `${LEGACY_DRAFT_IMPORT_PREFIX}${id}`;
+ const run = async (): Promise => {
+ const saved = storage.getItem(key);
+ const record =
+ saved === null
+ ? {
+ version: 1 as const,
+ id,
+ rawValue,
+ content: draftContentSchema.parse({ ...seed, prompt }),
+ acknowledged: false,
+ }
+ : importRecordSchema.parse(JSON.parse(saved));
+ if (record.id !== id || record.rawValue !== rawValue)
+ throw new Error(
+ "Legacy import recovery data does not match. The original has been retained.",
+ );
+ if (saved === null) storage.setItem(key, JSON.stringify(record));
+ if (!record.acknowledged) {
+ const response = await api.create(id, record.content);
+ result.draft = response.draft;
+ record.acknowledged = true;
+ storage.setItem(key, JSON.stringify(record));
+ await cacheDraftResource(queryClient, id, response.draft);
+ await invalidateDraftResources(queryClient);
+ } else {
+ result.draft = await api.get(id);
+ await cacheDraftResource(queryClient, id, result.draft);
+ }
+ const latest = storage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY);
+ if (latest === rawValue) storage.removeItem(LEGACY_NEW_THREAD_DRAFT_KEY);
+ else result.newerLegacyValue = latest !== null;
+ return result;
+ };
+ if (typeof navigator !== "undefined" && navigator.locks) {
+ return await navigator.locks.request(`bb.draft-import:${id}`, run);
+ }
+ return await run();
+ } catch (error) {
+ result.error =
+ error instanceof Error
+ ? error
+ : new Error(
+ "Could not import the old draft. Its original contents have been kept.",
+ );
+ return result;
+ }
+}
diff --git a/apps/app/src/lib/drafts/recovery.ts b/apps/app/src/lib/drafts/recovery.ts
new file mode 100644
index 00000000000..6f6d85154e7
--- /dev/null
+++ b/apps/app/src/lib/drafts/recovery.ts
@@ -0,0 +1,94 @@
+import { draftContentSchema, draftIdSchema } from "@bb/server-contract";
+import { z } from "zod";
+
+export const DRAFT_RECOVERY_PREFIX = "bb.draft-recovery.v1:";
+
+export const draftRecoverySchema = z.object({
+ version: z.literal(1),
+ id: draftIdSchema,
+ content: draftContentSchema,
+ baseRevision: z.number().int().positive().nullable(),
+ createContent: draftContentSchema.nullable(),
+ submission: z
+ .object({
+ revision: z.number().int().positive(),
+ content: draftContentSchema,
+ })
+ .nullable(),
+ blocked: z.enum(["conflict", "deleted"]).nullable(),
+ deleteRequested: z.boolean(),
+ forceRevision: z.boolean(),
+ updatedAt: z.number().nonnegative(),
+});
+
+export type DraftRecovery = z.infer;
+
+export interface StoredDraftRecovery {
+ key: string;
+ raw: string;
+ value: DraftRecovery;
+}
+
+export type DraftRecoveryStorage = Pick<
+ Storage,
+ "getItem" | "setItem" | "removeItem" | "key" | "length"
+>;
+
+export function readDraftRecoveries(
+ storage: DraftRecoveryStorage,
+ id?: string,
+): {
+ records: StoredDraftRecovery[];
+ error: Error | null;
+} {
+ const records: StoredDraftRecovery[] = [];
+ let error: Error | null = null;
+ try {
+ for (let index = 0; index < storage.length; index += 1) {
+ const key = storage.key(index);
+ if (!key?.startsWith(DRAFT_RECOVERY_PREFIX)) continue;
+ if (id !== undefined && !key.startsWith(`${DRAFT_RECOVERY_PREFIX}${id}:`))
+ continue;
+ const raw = storage.getItem(key);
+ if (raw === null) continue;
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ const value = draftRecoverySchema.parse(parsed);
+ if (id === undefined || value.id === id)
+ records.push({ key, raw, value });
+ } catch {
+ error = new Error(
+ "Some draft recovery data could not be read. It has been kept in browser storage.",
+ );
+ }
+ }
+ } catch {
+ error = new Error(
+ "Browser storage is unavailable. Keep this page open until your draft is saved.",
+ );
+ }
+ return {
+ records: records.sort((a, b) => b.value.updatedAt - a.value.updatedAt),
+ error,
+ };
+}
+
+export function removeUnchangedRecovery(
+ storage: DraftRecoveryStorage,
+ record: Pick,
+): void {
+ if (storage.getItem(record.key) === record.raw)
+ storage.removeItem(record.key);
+}
+
+export function browserDraftRecoveryStorage(): DraftRecoveryStorage {
+ return {
+ get length() {
+ return window.localStorage.length;
+ },
+ key: (index) => window.localStorage.key(index),
+ getItem: (key) => window.localStorage.getItem(key),
+ setItem: (key, value) => window.localStorage.setItem(key, value),
+ removeItem: (key) => window.localStorage.removeItem(key),
+ };
+}
diff --git a/apps/app/src/lib/drafts/resource-api.ts b/apps/app/src/lib/drafts/resource-api.ts
new file mode 100644
index 00000000000..a371b5a4ef7
--- /dev/null
+++ b/apps/app/src/lib/drafts/resource-api.ts
@@ -0,0 +1,103 @@
+import {
+ draftCreateResponseSchema,
+ draftDeleteResponseSchema,
+ draftListResponseSchema,
+ draftSchema,
+ draftSubmitResponseSchema,
+ type Draft,
+ type DraftContent,
+ type DraftCreateResponse,
+ type DraftListQuery,
+ type DraftSubmitResponse,
+} from "@bb/server-contract";
+import { allDraftQueryKeyPrefix } from "@/hooks/queries/query-keys";
+import { HttpError, request, requestOptions } from "../api";
+import { apiClient } from "../api-server";
+
+export const draftResourceQueryKey = (id: string) =>
+ [...allDraftQueryKeyPrefix(), "detail", id] as const;
+
+export const draftResourceListQueryKey = (query: DraftListQuery) =>
+ [...allDraftQueryKeyPrefix(), "list", query] as const;
+
+export interface DraftResourceApi {
+ get(id: string, signal?: AbortSignal): Promise;
+ create(id: string, content: DraftContent): Promise;
+ update(
+ id: string,
+ expectedRevision: number,
+ content: DraftContent,
+ ): Promise;
+ delete(id: string, expectedRevision: number): Promise;
+ submit(id: string, expectedRevision: number): Promise;
+}
+
+export function isDraftGoneError(error: unknown): boolean {
+ return (
+ error instanceof HttpError &&
+ (error.status === 404 ||
+ (error.status === 410 && error.code === "draft_gone"))
+ );
+}
+
+export const draftResourceApi: DraftResourceApi = {
+ async get(id, signal) {
+ try {
+ return draftSchema.parse(
+ await request(
+ apiClient.drafts[":id"].$get(
+ { param: { id } },
+ requestOptions(signal),
+ ),
+ ),
+ );
+ } catch (error) {
+ if (isDraftGoneError(error)) return null;
+ throw error;
+ }
+ },
+ async create(id, content) {
+ return draftCreateResponseSchema.parse(
+ await request(apiClient.drafts.$post({ json: { id, content } })),
+ );
+ },
+ async update(id, expectedRevision, content) {
+ return draftSchema.parse(
+ await request(
+ apiClient.drafts[":id"].$patch({
+ param: { id },
+ json: { expectedRevision, content },
+ }),
+ ),
+ );
+ },
+ async delete(id, expectedRevision) {
+ draftDeleteResponseSchema.parse(
+ await request(
+ apiClient.drafts[":id"].$delete({
+ param: { id },
+ json: { expectedRevision },
+ }),
+ ),
+ );
+ },
+ async submit(id, expectedRevision) {
+ return draftSubmitResponseSchema.parse(
+ await request(
+ apiClient.drafts[":id"].submit.$post({
+ param: { id },
+ json: { expectedRevision, origin: "app" },
+ }),
+ ),
+ );
+ },
+};
+
+export async function listDraftResources(
+ query: DraftListQuery,
+ signal?: AbortSignal,
+) {
+ return draftListResponseSchema.parse(
+ await request(apiClient.drafts.$get({ query }, requestOptions(signal))),
+ );
+}
diff --git a/apps/app/src/lib/drafts/resource-runtime.ts b/apps/app/src/lib/drafts/resource-runtime.ts
new file mode 100644
index 00000000000..f3c4b876af0
--- /dev/null
+++ b/apps/app/src/lib/drafts/resource-runtime.ts
@@ -0,0 +1,34 @@
+import type { QueryClient } from "@tanstack/react-query";
+import type { DraftContentInput } from "@bb/server-contract";
+import { appQueryClient } from "../app-query-client";
+import { draftResourceApi } from "./resource-api";
+import { browserDraftRecoveryStorage } from "./recovery";
+import { DraftResourceStore } from "./resource-store";
+
+const stores = new WeakMap();
+
+export function getDraftResourceStore(
+ queryClient = appQueryClient,
+): DraftResourceStore {
+ let store = stores.get(queryClient);
+ if (!store) {
+ store = new DraftResourceStore(
+ queryClient,
+ draftResourceApi,
+ browserDraftRecoveryStorage(),
+ );
+ stores.set(queryClient, store);
+ }
+ return store;
+}
+
+export function createNewThreadDraft(initial: DraftContentInput): string {
+ return getDraftResourceStore().create(initial);
+}
+
+export function initializeNewThreadDraft(
+ id: string,
+ initial: DraftContentInput,
+): boolean {
+ return getDraftResourceStore().initialize(id, initial);
+}
diff --git a/apps/app/src/lib/drafts/resource-store-storage.test.ts b/apps/app/src/lib/drafts/resource-store-storage.test.ts
new file mode 100644
index 00000000000..2cf579285d4
--- /dev/null
+++ b/apps/app/src/lib/drafts/resource-store-storage.test.ts
@@ -0,0 +1,241 @@
+// @vitest-environment jsdom
+
+import { webcrypto } from "node:crypto";
+import { QueryClient } from "@tanstack/react-query";
+import {
+ draftContentSchema,
+ type Draft,
+ type DraftContent,
+} from "@bb/server-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { HttpError } from "../api";
+import { draftResourceQueryKey, type DraftResourceApi } from "./resource-api";
+import { browserDraftRecoveryStorage, readDraftRecoveries } from "./recovery";
+import { DraftResourceStore } from "./resource-store";
+
+const id = "drf_storage_events";
+const stores: DraftResourceStore[] = [];
+const clients: QueryClient[] = [];
+
+beforeEach(() => {
+ vi.stubGlobal("crypto", webcrypto);
+ const held = new Set();
+ vi.stubGlobal("navigator", {
+ locks: {
+ async request(
+ name: string,
+ options: { ifAvailable: boolean },
+ callback: (lock: { name: string } | null) => Promise,
+ ) {
+ await Promise.resolve();
+ if (held.has(name) && options.ifAvailable) return callback(null);
+ held.add(name);
+ return callback({ name }).finally(() => held.delete(name));
+ },
+ },
+ });
+});
+
+afterEach(() => {
+ for (const store of stores.splice(0)) store.dispose();
+ for (const client of clients.splice(0)) client.clear();
+ localStorage.clear();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+function fixture() {
+ let saved: Draft = {
+ id,
+ revision: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ content: draftContentSchema.parse({ prompt: { text: "Saved text" } }),
+ };
+ const api: DraftResourceApi = {
+ get: vi.fn(async () => saved),
+ create: vi.fn(async () => {
+ throw new Error("Unexpected create");
+ }),
+ update: vi.fn(
+ async (_id: string, revision: number, content: DraftContent) => {
+ if (revision !== saved.revision)
+ throw new HttpError({
+ status: 409,
+ code: "draft_revision_conflict",
+ message: "Changed draft",
+ });
+ saved = {
+ ...saved,
+ revision: revision + 1,
+ updatedAt: revision + 1,
+ content,
+ };
+ return saved;
+ },
+ ),
+ delete: vi.fn(async () => {}),
+ submit: vi.fn(async () => {
+ throw new Error("Unexpected submit");
+ }),
+ };
+ const createStore = () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+ });
+ clients.push(client);
+ client.setQueryData(draftResourceQueryKey(id), saved);
+ const store = new DraftResourceStore(
+ client,
+ api,
+ browserDraftRecoveryStorage(),
+ 60_000,
+ );
+ stores.push(store);
+ return store;
+ };
+ return { api, createStore, getSaved: () => saved };
+}
+
+function edit(store: DraftResourceStore, text: string): void {
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text },
+ }));
+}
+
+function recoveries() {
+ return readDraftRecoveries(localStorage, id).records;
+}
+
+function dispatchStorage(key: string): void {
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key,
+ newValue: localStorage.getItem(key),
+ storageArea: localStorage,
+ }),
+ );
+}
+
+describe("draft recovery writer ownership", () => {
+ it("does not resume a live writer's checkpoint when the passive window opens after the write", async () => {
+ vi.useFakeTimers();
+ const { api, createStore, getSaved } = fixture();
+ const owner = createStore();
+ edit(owner, "Pending before the second window opens");
+ const key = recoveries()[0]!.key;
+
+ const passive = createStore();
+ passive.resumeRecoveries();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(passive.getSnapshot(id).status).toBe("saved");
+ expect(passive.getRecoverableSnapshot()).toHaveLength(0);
+ expect(recoveries()).toHaveLength(1);
+
+ edit(owner, "The owner keeps typing");
+ dispatchStorage(key);
+ await vi.advanceTimersByTimeAsync(60_000);
+ dispatchStorage(key);
+ await passive.load(id);
+ expect(api.update).toHaveBeenCalledTimes(1);
+ expect(getSaved().content.prompt.text).toBe("The owner keeps typing");
+ expect(owner.getSnapshot(id).status).toBe("saved");
+ expect(passive.getSnapshot(id).status).toBe("saved");
+ expect(recoveries()).toHaveLength(0);
+ });
+
+ it.each([false, true])(
+ "keeps a passive window clean across foreign updates and acknowledgment (already open: %s)",
+ async (alreadyOpen) => {
+ const { createStore, getSaved } = fixture();
+ const owner = createStore();
+ const passive = createStore();
+ if (alreadyOpen) passive.getSnapshot(id);
+
+ edit(owner, "First character");
+ const key = recoveries()[0]!.key;
+ dispatchStorage(key);
+ expect(passive.getSnapshot(id).status).toBe("saved");
+ expect(passive.getSnapshot(id).recoveryCopies).toHaveLength(0);
+
+ edit(owner, "More characters");
+ dispatchStorage(key);
+ expect(recoveries()).toHaveLength(1);
+ expect(owner.getSnapshot(id).status).toBe("saving");
+ expect(passive.getSnapshot(id).status).toBe("saved");
+ expect(passive.getRecoverableSnapshot()).toHaveLength(0);
+
+ await owner.flush(id);
+ dispatchStorage(key);
+ await passive.load(id);
+ expect(getSaved().content.prompt.text).toBe("More characters");
+ expect(passive.getSnapshot(id).content?.prompt.text).toBe(
+ "More characters",
+ );
+ expect(passive.getSnapshot(id).status).toBe("saved");
+ expect(passive.getSnapshot(id).recoveryCopies).toHaveLength(0);
+ expect(recoveries()).toHaveLength(0);
+ },
+ );
+
+ it("replaces foreign checkpoints by writer and retains real competing edits through CAS", async () => {
+ const { createStore, getSaved } = fixture();
+ const first = createStore();
+ const second = createStore();
+ edit(first, "First writer");
+ const firstKey = recoveries()[0]!.key;
+ dispatchStorage(firstKey);
+ edit(second, "Second writer");
+ const secondKey = recoveries().find(
+ (record) => record.key !== firstKey,
+ )!.key;
+ dispatchStorage(secondKey);
+
+ edit(first, "First writer newer");
+ dispatchStorage(firstKey);
+ expect(second.getSnapshot(id).content?.prompt.text).toBe("Second writer");
+ expect(
+ second
+ .getSnapshot(id)
+ .recoveryCopies.map((content) => content.prompt.text),
+ ).toEqual(["First writer newer"]);
+ expect(recoveries()).toHaveLength(2);
+
+ await first.flush(id);
+ dispatchStorage(firstKey);
+ expect(second.getSnapshot(id).recoveryCopies).toHaveLength(0);
+ await expect(second.flush(id)).rejects.toMatchObject({
+ code: "draft_revision_conflict",
+ });
+ expect(second.getSnapshot(id).status).toBe("conflict");
+ expect(second.getSnapshot(id).content?.prompt.text).toBe("Second writer");
+ expect(second.getSnapshot(id).recoveryCopies[0]?.prompt.text).toBe(
+ "Second writer",
+ );
+ expect(getSaved().content.prompt.text).toBe("First writer newer");
+ expect(
+ recoveries().map((record) => record.value.content.prompt.text),
+ ).toEqual(["Second writer"]);
+ });
+
+ it("resumes an existing v1 owner checkpoint after reload", async () => {
+ vi.useFakeTimers();
+ const { api, createStore, getSaved } = fixture();
+ const owner = createStore();
+ edit(owner, "Unacknowledged text");
+ vi.mocked(api.update).mockRejectedValueOnce(new Error("offline"));
+ await expect(owner.flush(id)).rejects.toThrow("offline");
+ owner.dispose();
+
+ const reopened = createStore();
+ expect(reopened.getSnapshot(id).content?.prompt.text).toBe(
+ "Unacknowledged text",
+ );
+ reopened.resumeRecoveries();
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(getSaved().content.prompt.text).toBe("Unacknowledged text");
+ expect(reopened.getSnapshot(id).status).toBe("saved");
+ expect(recoveries()).toHaveLength(0);
+ });
+});
diff --git a/apps/app/src/lib/drafts/resource-store.test.ts b/apps/app/src/lib/drafts/resource-store.test.ts
new file mode 100644
index 00000000000..ea480e2f698
--- /dev/null
+++ b/apps/app/src/lib/drafts/resource-store.test.ts
@@ -0,0 +1,785 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { QueryClient } from "@tanstack/react-query";
+import {
+ draftContentSchema,
+ type Draft,
+ type DraftContent,
+ type DraftCreateResponse,
+ type DraftSubmitResponse,
+} from "@bb/server-contract";
+import { makeThreadWithRuntime } from "@bb/test-helpers/domain-fixtures";
+import { HttpError } from "../api";
+import { DraftResourceStore } from "./resource-store";
+import { draftResourceQueryKey, type DraftResourceApi } from "./resource-api";
+import {
+ DRAFT_RECOVERY_PREFIX,
+ readDraftRecoveries,
+ type DraftRecoveryStorage,
+} from "./recovery";
+import {
+ importLegacyNewThreadDraft,
+ LEGACY_DRAFT_IMPORT_PREFIX,
+ LEGACY_NEW_THREAD_DRAFT_KEY,
+} from "./legacy-import";
+
+class RecoveryStorage implements DraftRecoveryStorage {
+ values = new Map();
+ failWrites = false;
+ failRemoves = false;
+ get length() {
+ return this.values.size;
+ }
+ key(index: number) {
+ return Array.from(this.values.keys())[index] ?? null;
+ }
+ getItem(key: string) {
+ return this.values.get(key) ?? null;
+ }
+ setItem(key: string, value: string) {
+ if (this.failWrites) throw new Error("quota exceeded");
+ this.values.set(key, value);
+ }
+ removeItem(key: string) {
+ if (this.failRemoves) throw new Error("storage unavailable");
+ this.values.delete(key);
+ }
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((onResolve, onReject) => {
+ resolve = onResolve;
+ reject = onReject;
+ });
+ return { promise, resolve, reject };
+}
+
+function createServer() {
+ const records = new Map();
+ const originals = new Map();
+ const receipts = new Map();
+ const creates: Array<{ id: string; content: DraftContent }> = [];
+ const updates: Array<{
+ id: string;
+ revision: number;
+ content: DraftContent;
+ }> = [];
+ const api: DraftResourceApi = {
+ async get(id) {
+ return records.get(id) ?? null;
+ },
+ async create(id, content) {
+ creates.push({ id, content });
+ const fingerprint = JSON.stringify(content);
+ if (originals.has(id)) {
+ if (originals.get(id) !== fingerprint)
+ throw new HttpError({
+ status: 409,
+ code: "draft_create_conflict",
+ message: "Different create",
+ });
+ return { id, draft: records.get(id) ?? null };
+ }
+ originals.set(id, fingerprint);
+ const draft = { id, content, revision: 1, createdAt: 1, updatedAt: 1 };
+ records.set(id, draft);
+ return { id, draft };
+ },
+ async update(id, revision, content) {
+ updates.push({ id, revision, content });
+ const current = records.get(id);
+ if (!current)
+ throw new HttpError({ status: 404, message: "Missing draft" });
+ if (current.revision !== revision)
+ throw new HttpError({
+ status: 409,
+ code: "draft_revision_conflict",
+ message: "Changed draft",
+ });
+ const next = {
+ ...current,
+ content,
+ revision: revision + 1,
+ updatedAt: revision + 1,
+ };
+ records.set(id, next);
+ return next;
+ },
+ async delete(id, revision) {
+ const current = records.get(id);
+ if (!current)
+ throw new HttpError({ status: 404, message: "Missing draft" });
+ if (current.revision !== revision)
+ throw new HttpError({
+ status: 409,
+ code: "draft_revision_conflict",
+ message: "Changed draft",
+ });
+ records.delete(id);
+ },
+ async submit(id, revision) {
+ const key = `${id}:${revision}`;
+ const existing = receipts.get(key);
+ if (existing) return existing;
+ const current = records.get(id);
+ if (!current || current.revision !== revision)
+ throw new HttpError({
+ status: 409,
+ code: "draft_revision_conflict",
+ message: "Changed draft",
+ });
+ records.delete(id);
+ const response = {
+ thread: makeThreadWithRuntime({ id: `thr_${receipts.size}` }),
+ draft: null,
+ };
+ receipts.set(key, response);
+ return response;
+ },
+ };
+ return { api, records, originals, receipts, creates, updates };
+}
+
+const stores: DraftResourceStore[] = [];
+const clients: QueryClient[] = [];
+
+function fixture(server = createServer(), storage = new RecoveryStorage()) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+ });
+ clients.push(queryClient);
+ const store = new DraftResourceStore(
+ queryClient,
+ server.api,
+ storage,
+ 60_000,
+ );
+ stores.push(store);
+ return { store, queryClient, storage, server };
+}
+
+afterEach(() => {
+ for (const store of stores.splice(0)) store.dispose();
+ for (const client of clients.splice(0)) client.clear();
+});
+
+const initial = () =>
+ draftContentSchema.parse({
+ projectId: "proj_one",
+ sectionId: "section_one",
+ prompt: {
+ text: "First draft",
+ mentions: [
+ {
+ start: 0,
+ end: 5,
+ resource: {
+ kind: "plugin",
+ pluginId: "plugin-one",
+ itemId: "item-one",
+ label: "First",
+ },
+ },
+ ],
+ attachments: [
+ {
+ type: "localFile",
+ path: "uploads/a.txt",
+ name: "a.txt",
+ sizeBytes: 12,
+ },
+ ],
+ },
+ options: { model: "model-one", environment: { type: "project-default" } },
+ });
+
+describe("draft resource recovery", () => {
+ it("reports a failed initialization checkpoint and retries it without replacing the original contents", () => {
+ const { store, storage } = fixture();
+ const id = "drf_migrated_identity";
+ storage.failWrites = true;
+ expect(store.initialize(id, initial())).toBe(false);
+ expect(readDraftRecoveries(storage, id).records).toHaveLength(0);
+ storage.failWrites = false;
+ expect(
+ store.initialize(id, { prompt: { text: "Different retry seed" } }),
+ ).toBe(true);
+ expect(readDraftRecoveries(storage, id).records[0]?.value.content).toEqual(
+ initial(),
+ );
+ });
+
+ it("exposes independent IDs immediately and stores all contents and choices before create", async () => {
+ const { store, storage, server } = fixture();
+ const first = store.create(initial());
+ const second = store.create({
+ projectId: "proj_two",
+ prompt: { text: "Second draft" },
+ options: { model: "model-two" },
+ });
+ expect(first).not.toBe(second);
+ expect(store.getRecoverableSnapshot().map((draft) => draft.id)).toEqual([
+ first,
+ second,
+ ]);
+ expect(
+ readDraftRecoveries(storage, first).records[0]?.value.content,
+ ).toEqual(initial());
+ await store.flush(first);
+ expect(server.records.get(first)?.content).toEqual(initial());
+ expect(store.getSnapshot(second).content?.options.model).toBe("model-two");
+ expect(readDraftRecoveries(storage, first).records).toHaveLength(0);
+ });
+
+ it("retries the immutable initial create after a lost acknowledgment and saves newer edits", async () => {
+ const { store, server } = fixture();
+ const originalCreate = server.api.create;
+ let fail = true;
+ server.api.create = async (id, content) => {
+ const response = await originalCreate(id, content);
+ if (fail) {
+ fail = false;
+ throw new Error("response lost");
+ }
+ return response;
+ };
+ const id = store.create(initial());
+ await expect(store.flush(id)).rejects.toThrow("response lost");
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Newer text" },
+ options: { ...content.options, model: "new-model" },
+ }));
+ await store.retry(id);
+ expect(server.creates).toEqual([
+ { id, content: initial() },
+ { id, content: initial() },
+ ]);
+ expect(server.records.get(id)?.content.prompt.text).toBe("Newer text");
+ expect(server.records.get(id)?.content.options.model).toBe("new-model");
+ expect(store.getSnapshot(id).status).toBe("saved");
+ });
+
+ it("keeps edits made during create and serializes updates for one identity", async () => {
+ const { store, server } = fixture();
+ const started = deferred();
+ const finish = deferred();
+ const originalCreate = server.api.create;
+ server.api.create = async (id, content) => {
+ const response = await originalCreate(id, content);
+ started.resolve();
+ await finish.promise;
+ return response;
+ };
+ const id = store.create(initial());
+ const first = store.flush(id);
+ await started.promise;
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "During create" },
+ }));
+ const second = store.flush(id);
+ finish.resolve({ id, draft: null });
+ await Promise.all([first, second]);
+ expect(server.creates).toHaveLength(1);
+ expect(server.updates).toHaveLength(1);
+ expect(server.records.get(id)?.content.prompt.text).toBe("During create");
+ });
+
+ it("retains a failed update through reload and clears recovery only after acknowledgment", async () => {
+ const { store, server, storage } = fixture();
+ const id = store.create(initial());
+ await store.flush(id);
+ const update = server.api.update;
+ server.api.update = async () => {
+ throw new Error("offline");
+ };
+ store.edit(id, (content) => ({
+ ...content,
+ projectId: "proj_two",
+ prompt: { ...content.prompt, text: "Pending text" },
+ }));
+ await expect(store.flush(id)).rejects.toThrow("offline");
+ store.dispose();
+ server.api.update = update;
+ const reopened = fixture(server, storage);
+ await reopened.store.load(id);
+ expect(reopened.store.getSnapshot(id).content?.prompt.text).toBe(
+ "Pending text",
+ );
+ await reopened.store.flush(id);
+ expect(server.records.get(id)?.content.projectId).toBe("proj_two");
+ expect(readDraftRecoveries(storage, id).records).toHaveLength(0);
+ });
+
+ it("preserves both CAS-racing edits and requires an explicit copy or remote reload", async () => {
+ const first = fixture();
+ const id = first.store.create(initial());
+ await first.store.flush(id);
+ const second = fixture(first.server, new RecoveryStorage());
+ await second.store.load(id);
+ first.store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "First client edits" },
+ }));
+ second.store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Second client edits" },
+ }));
+ await second.store.flush(id);
+ await expect(first.store.flush(id)).rejects.toMatchObject({
+ code: "draft_revision_conflict",
+ });
+ expect(first.store.getSnapshot(id).status).toBe("conflict");
+ expect(first.store.getSnapshot(id).content?.prompt.text).toBe(
+ "First client edits",
+ );
+ const copy = first.store.saveLocalAsCopy(id);
+ await first.store.flush(copy);
+ expect(first.server.records.get(copy)?.content.prompt.text).toBe(
+ "First client edits",
+ );
+ await first.store.reloadRemote(id);
+ expect(first.store.getSnapshot(id).content?.prompt.text).toBe(
+ "Second client edits",
+ );
+ });
+
+ it("does not overwrite a newer server revision noticed by a pending editor", async () => {
+ const { store, server, queryClient } = fixture();
+ const id = store.create(initial());
+ await store.flush(id);
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Unsaved local" },
+ }));
+ const remote = await server.api.update(
+ id,
+ 1,
+ draftContentSchema.parse({ prompt: { text: "Remote" } }),
+ );
+ queryClient.setQueryData(draftResourceQueryKey(id), remote);
+ expect(store.getSnapshot(id).status).toBe("conflict");
+ await expect(store.flush(id)).rejects.toThrow("recovery");
+ expect(server.records.get(id)?.content.prompt.text).toBe("Remote");
+ });
+
+ it("cancels a stale detail read before publishing an acknowledged update", async () => {
+ const { store, server, queryClient } = fixture();
+ const id = store.create(initial());
+ await store.flush(id);
+ const stale = server.records.get(id)!;
+ const finish = deferred();
+ const loading = queryClient
+ .fetchQuery({
+ queryKey: draftResourceQueryKey(id),
+ queryFn: () => finish.promise,
+ staleTime: 0,
+ })
+ .catch(() => null);
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Acknowledged latest" },
+ }));
+ await store.flush(id);
+ finish.resolve(stale);
+ await loading;
+ expect(store.getSnapshot(id).content?.prompt.text).toBe(
+ "Acknowledged latest",
+ );
+ expect(
+ queryClient.getQueryData(draftResourceQueryKey(id))?.revision,
+ ).toBe(2);
+ });
+
+ it("retains malformed records and the last durable buffer when quota is exhausted", async () => {
+ const storage = new RecoveryStorage();
+ const malformedKey = `${DRAFT_RECOVERY_PREFIX}drf_malformed:old`;
+ storage.setItem(malformedKey, "{broken");
+ const { store } = fixture(createServer(), storage);
+ const id = store.create(initial());
+ const before = readDraftRecoveries(storage, id).records[0]?.raw;
+ storage.failWrites = true;
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Memory survives" },
+ }));
+ expect(store.getSnapshot(id).persistenceError).not.toBeNull();
+ expect(store.getSnapshot(id).content?.prompt.text).toBe("Memory survives");
+ expect(readDraftRecoveries(storage, id).records[0]?.raw).toBe(before);
+ expect(storage.getItem(malformedKey)).toBe("{broken");
+ storage.failWrites = false;
+ await store.retry(id);
+ expect(store.getSnapshot(id).persistenceError).toBeNull();
+ expect(storage.getItem(malformedKey)).toBe("{broken");
+ });
+
+ it("opens a remotely deleted draft blank and keeps its unsaved contents recoverable", async () => {
+ const { store, server } = fixture();
+ const id = store.create(initial());
+ await store.flush(id);
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Must recover" },
+ }));
+ await server.api.delete(id, 1);
+ await store.load(id);
+ expect(store.getSnapshot(id).status).toBe("deleted");
+ expect(store.getSnapshot(id).content?.prompt.text).toBe("");
+ expect(store.getSnapshot(id).recoveryCopies[0]?.prompt.text).toBe(
+ "Must recover",
+ );
+ await expect(store.flush(id)).rejects.toThrow();
+ expect(server.creates).toHaveLength(1);
+ });
+
+ it("waits for an in-flight write before deletion and cannot resurrect that identity", async () => {
+ const { store, server } = fixture();
+ const id = store.create(initial());
+ await store.flush(id);
+ const update = server.api.update;
+ const started = deferred();
+ const finish = deferred();
+ server.api.update = async (...args) => {
+ started.resolve();
+ await finish.promise;
+ return update(...args);
+ };
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Pending update" },
+ }));
+ const saving = store.flush(id);
+ await started.promise;
+ const deleting = store.delete(id);
+ expect(store.getSnapshot(id).content?.prompt.text).toBe("");
+ finish.resolve();
+ await saving;
+ await deleting;
+ await expect(store.flush(id)).rejects.toThrow();
+ expect(server.records.has(id)).toBe(false);
+ expect(store.getSnapshot(id).status).toBe("deleted");
+ });
+
+ it("reuses a consumed create tombstone instead of resurrecting a lost initial acknowledgment", async () => {
+ const { store, server } = fixture();
+ const create = server.api.create;
+ let first = true;
+ server.api.create = async (id, content) => {
+ const response = await create(id, content);
+ if (first) {
+ first = false;
+ await server.api.delete(id, 1);
+ throw new Error("lost response");
+ }
+ return response;
+ };
+ const id = store.create(initial());
+ await expect(store.flush(id)).rejects.toThrow("lost response");
+ await expect(store.retry(id)).rejects.toThrow("consumed or deleted");
+ expect(store.getSnapshot(id).status).toBe("deleted");
+ expect(store.getSnapshot(id).recoveryCopies[0]).toEqual(initial());
+ expect(server.records.size).toBe(0);
+ });
+});
+
+describe("draft submission recovery", () => {
+ it("releases a definite submission conflict so the saved remote version can be accepted", async () => {
+ const { store, server, storage } = fixture();
+ const id = store.create(initial());
+ const submit = server.api.submit;
+ const started = deferred();
+ const finish = deferred();
+ server.api.submit = async (...args) => {
+ started.resolve();
+ await finish.promise;
+ return submit(...args);
+ };
+ const pending = store.submit(id);
+ await started.promise;
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Local edit during submit" },
+ }));
+ const remote = await server.api.update(
+ id,
+ 1,
+ draftContentSchema.parse({
+ prompt: { text: "Saved elsewhere before submission" },
+ }),
+ );
+ finish.resolve();
+ await expect(pending).rejects.toMatchObject({
+ status: 409,
+ code: "draft_revision_conflict",
+ });
+ expect(server.receipts.size).toBe(0);
+ expect(store.getSnapshot(id).status).toBe("conflict");
+ expect(store.getSnapshot(id).content?.prompt.text).toBe(
+ "Local edit during submit",
+ );
+ expect(
+ readDraftRecoveries(storage, id).records[0]?.value.submission,
+ ).toBeNull();
+ await store.reloadRemote(id);
+ expect(store.getSnapshot(id).status).toBe("saved");
+ expect(store.getSnapshot(id).content).toEqual(remote.content);
+ expect(readDraftRecoveries(storage, id).records).toHaveLength(0);
+ });
+
+ it("moves edits typed during consumption into an explicitly returned fresh identity", async () => {
+ const { store, server } = fixture();
+ const id = store.create(initial());
+ const submit = server.api.submit;
+ const started = deferred();
+ const finish = deferred();
+ server.api.submit = async (...args) => {
+ started.resolve();
+ await finish.promise;
+ return submit(...args);
+ };
+ const submitting = store.submit(id);
+ await started.promise;
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "New work during submit" },
+ sectionId: "section_two",
+ }));
+ finish.resolve();
+ const result = await submitting;
+ expect(result.recoveryDraftId).not.toBeNull();
+ const recovered = result.recoveryDraftId!;
+ expect(recovered).not.toBe(id);
+ expect(store.getSnapshot(id).content?.prompt.text).toBe("");
+ expect(store.getSnapshot(recovered).content?.prompt.text).toBe(
+ "New work during submit",
+ );
+ await store.flush(recovered);
+ expect(server.records.get(recovered)?.content.sectionId).toBe(
+ "section_two",
+ );
+ expect(server.receipts.size).toBe(1);
+ });
+
+ it("retries the exact submitted revision after reload and a lost successful response", async () => {
+ const { store, server, storage } = fixture();
+ const submit = server.api.submit;
+ const revisions: number[] = [];
+ let fail = true;
+ server.api.submit = async (id, revision) => {
+ revisions.push(revision);
+ const result = await submit(id, revision);
+ if (fail) {
+ fail = false;
+ throw new Error("lost submit response");
+ }
+ return result;
+ };
+ const id = store.create(initial());
+ await expect(store.submit(id)).rejects.toThrow("lost submit response");
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "After response loss" },
+ }));
+ store.dispose();
+ const reopened = fixture(server, storage);
+ await reopened.store.load(id);
+ await expect(reopened.store.reloadRemote(id)).rejects.toThrow(
+ "Retry the pending submission before discarding recovery data",
+ );
+ const result = await reopened.store.retry(id);
+ expect(revisions).toEqual([1, 1]);
+ expect(server.receipts.size).toBe(1);
+ expect(result).not.toBeNull();
+ expect(result?.thread.id).toBe("thr_0");
+ expect(result?.recoveryDraftId).not.toBeNull();
+ expect(
+ reopened.store.getSnapshot(result!.recoveryDraftId!).content?.prompt.text,
+ ).toBe("After response loss");
+ });
+
+ it("retains the same identity when submit returns a newer accepted remote edit", async () => {
+ const { store, server } = fixture();
+ const id = store.create(initial());
+ const started = deferred();
+ const finish = deferred();
+ server.api.submit = async () => {
+ started.resolve();
+ await finish.promise;
+ return {
+ thread: makeThreadWithRuntime(),
+ draft: server.records.get(id) ?? null,
+ };
+ };
+ const pending = store.submit(id);
+ await started.promise;
+ const newer = draftContentSchema.parse({
+ prompt: { text: "Other tab accepted" },
+ });
+ await server.api.update(id, 1, newer);
+ finish.resolve();
+ const result = await pending;
+ expect(result.recoveryDraftId).toBeNull();
+ expect(store.getSnapshot(id).content).toEqual(newer);
+ expect(store.getSnapshot(id).status).toBe("saved");
+ });
+
+ it("keeps both local and newer remote edits when submit returns a concurrently saved revision", async () => {
+ const { store, server } = fixture();
+ const id = store.create(initial());
+ const started = deferred();
+ const finish = deferred();
+ server.api.submit = async () => {
+ started.resolve();
+ await finish.promise;
+ return {
+ thread: makeThreadWithRuntime(),
+ draft: server.records.get(id) ?? null,
+ };
+ };
+ const pending = store.submit(id);
+ await started.promise;
+ store.edit(id, (content) => ({
+ ...content,
+ prompt: { ...content.prompt, text: "Local newer" },
+ }));
+ await server.api.update(
+ id,
+ 1,
+ draftContentSchema.parse({ prompt: { text: "Remote newer" } }),
+ );
+ finish.resolve();
+ await pending;
+ expect(store.getSnapshot(id).status).toBe("conflict");
+ expect(store.getSnapshot(id).content?.prompt.text).toBe("Local newer");
+ expect(server.records.get(id)?.content.prompt.text).toBe("Remote newer");
+ });
+});
+
+describe("acknowledged singleton import", () => {
+ it("retries one stable original snapshot and preserves attachments and supplied selections", async () => {
+ const { server, storage, queryClient } = fixture();
+ const raw = JSON.stringify(initial().prompt);
+ storage.setItem(LEGACY_NEW_THREAD_DRAFT_KEY, raw);
+ const create = server.api.create;
+ let fail = true;
+ server.api.create = async (id, content) => {
+ const response = await create(id, content);
+ if (fail) {
+ fail = false;
+ throw new Error("response lost");
+ }
+ return response;
+ };
+ const deps = { api: server.api, storage, queryClient };
+ const seed = {
+ projectId: initial().projectId,
+ sectionId: initial().sectionId,
+ options: initial().options,
+ };
+ const first = await importLegacyNewThreadDraft(seed, deps);
+ expect(first.error).not.toBeNull();
+ expect(storage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY)).toBe(raw);
+ const second = await importLegacyNewThreadDraft(
+ { projectId: "proj_changed" },
+ deps,
+ );
+ expect(second.error).toBeNull();
+ expect(second.id).toBe(first.id);
+ expect(server.creates[0]?.content).toEqual(initial());
+ expect(server.creates[1]?.content).toEqual(initial());
+ expect(storage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY)).toBeNull();
+ });
+
+ it("leaves a newer singleton value untouched and imports it separately", async () => {
+ const { server, storage, queryClient } = fixture();
+ storage.setItem(
+ LEGACY_NEW_THREAD_DRAFT_KEY,
+ JSON.stringify(initial().prompt),
+ );
+ const create = server.api.create;
+ const changed = JSON.stringify({
+ text: "Typed during import",
+ mentions: [],
+ attachments: [],
+ });
+ server.api.create = async (id, content) => {
+ storage.setItem(LEGACY_NEW_THREAD_DRAFT_KEY, changed);
+ return create(id, content);
+ };
+ const deps = { api: server.api, storage, queryClient };
+ const first = await importLegacyNewThreadDraft(
+ { projectId: "proj_one" },
+ deps,
+ );
+ expect(first.newerLegacyValue).toBe(true);
+ expect(storage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY)).toBe(changed);
+ server.api.create = create;
+ const second = await importLegacyNewThreadDraft(
+ { projectId: "proj_one" },
+ deps,
+ );
+ expect(second.id).not.toBe(first.id);
+ expect(server.records.size).toBe(2);
+ });
+
+ it("cannot resurrect an acknowledged import after removal fails and the server deletes it", async () => {
+ const { server, storage, queryClient } = fixture();
+ const raw = JSON.stringify(initial().prompt);
+ storage.setItem(LEGACY_NEW_THREAD_DRAFT_KEY, raw);
+ storage.failRemoves = true;
+ const deps = { api: server.api, storage, queryClient };
+ const first = await importLegacyNewThreadDraft({}, deps);
+ expect(first.error).not.toBeNull();
+ expect(first.id).not.toBeNull();
+ await server.api.delete(first.id!, 1);
+ storage.failRemoves = false;
+ const retry = await importLegacyNewThreadDraft({}, deps);
+ expect(retry.error).toBeNull();
+ expect(retry.id).toBe(first.id);
+ expect(retry.draft).toBeNull();
+ expect(server.creates).toHaveLength(1);
+ expect(server.records.size).toBe(0);
+ });
+
+ it("never deletes malformed legacy/import records and does not create without a durable import checkpoint", async () => {
+ const { server, storage, queryClient } = fixture();
+ const deps = { api: server.api, storage, queryClient };
+ storage.setItem(LEGACY_NEW_THREAD_DRAFT_KEY, "{broken");
+ expect((await importLegacyNewThreadDraft({}, deps)).error).not.toBeNull();
+ expect(storage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY)).toBe("{broken");
+ const raw = JSON.stringify(initial().prompt);
+ storage.setItem(LEGACY_NEW_THREAD_DRAFT_KEY, raw);
+ storage.failWrites = true;
+ const quota = await importLegacyNewThreadDraft({}, deps);
+ expect(quota.error).not.toBeNull();
+ expect(server.creates).toHaveLength(0);
+ storage.failWrites = false;
+ storage.setItem(
+ `${LEGACY_DRAFT_IMPORT_PREFIX}${quota.id}`,
+ "{broken checkpoint",
+ );
+ expect((await importLegacyNewThreadDraft({}, deps)).error).not.toBeNull();
+ expect(storage.getItem(`${LEGACY_DRAFT_IMPORT_PREFIX}${quota.id}`)).toBe(
+ "{broken checkpoint",
+ );
+ expect(storage.getItem(LEGACY_NEW_THREAD_DRAFT_KEY)).toBe(raw);
+ });
+
+ it("deduplicates concurrent imports of the same singleton", async () => {
+ const { server, storage, queryClient } = fixture();
+ storage.setItem(
+ LEGACY_NEW_THREAD_DRAFT_KEY,
+ JSON.stringify(initial().prompt),
+ );
+ const deps = { api: server.api, storage, queryClient };
+ const results = await Promise.all([
+ importLegacyNewThreadDraft({ projectId: "proj_one" }, deps),
+ importLegacyNewThreadDraft({ projectId: "proj_one" }, deps),
+ ]);
+ expect(results[0]?.id).toBe(results[1]?.id);
+ expect(results.every((result) => result.error === null)).toBe(true);
+ expect(server.records.size).toBe(1);
+ });
+});
diff --git a/apps/app/src/lib/drafts/resource-store.ts b/apps/app/src/lib/drafts/resource-store.ts
new file mode 100644
index 00000000000..39c85632325
--- /dev/null
+++ b/apps/app/src/lib/drafts/resource-store.ts
@@ -0,0 +1,903 @@
+import type { QueryClient } from "@tanstack/react-query";
+import {
+ draftContentSchema,
+ draftIdSchema,
+ type Draft,
+ type DraftContent,
+ type DraftContentInput,
+ type DraftSubmitResponse,
+} from "@bb/server-contract";
+import { isPromptDraftEmpty } from "@bb/client-core";
+import {
+ cacheDraftResource,
+ invalidateDraftLists,
+} from "@/hooks/cache-owners/draft-cache-owner";
+import { HttpError } from "../api";
+import {
+ draftResourceQueryKey,
+ isDraftGoneError,
+ type DraftResourceApi,
+} from "./resource-api";
+import {
+ DRAFT_RECOVERY_PREFIX,
+ readDraftRecoveries,
+ removeUnchangedRecovery,
+ type DraftRecovery,
+ type DraftRecoveryStorage,
+ type StoredDraftRecovery,
+} from "./recovery";
+
+export type DraftResourceStatus =
+ | "loading"
+ | "saving"
+ | "saved"
+ | "error"
+ | "conflict"
+ | "deleted";
+
+export interface DraftResourceSnapshot {
+ id: string;
+ content: DraftContent | null;
+ status: DraftResourceStatus;
+ error: Error | null;
+ persistenceError: Error | null;
+ recoveryCopies: readonly DraftContent[];
+}
+
+export interface RecoverableDraftSnapshot {
+ id: string;
+ content: DraftContent;
+ updatedAt: number;
+ status: "saving" | "error" | "conflict" | "deleted";
+ error: Error | null;
+ persistenceError: Error | null;
+}
+
+export type DraftResourceSubmitResult = DraftSubmitResponse & {
+ recoveryDraftId: string | null;
+};
+
+interface Entry {
+ id: string;
+ buffer: DraftRecovery | null;
+ ownsBuffer: boolean;
+ sources: StoredDraftRecovery[];
+ alternatives: StoredDraftRecovery[];
+ persistenceError: Error | null;
+ error: Error | null;
+ deleted: boolean;
+ busy: Promise | null;
+ timer: ReturnType | null;
+ listeners: Set<() => void>;
+ snapshot: DraftResourceSnapshot;
+}
+
+const EMPTY_CONTENT = draftContentSchema.parse({});
+const WRITER_LOCK_PREFIX = "bb.draft-recovery-writer:";
+
+function sameContent(left: DraftContent, right: DraftContent): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+function asError(error: unknown): Error {
+ return error instanceof Error
+ ? error
+ : new Error("Draft request failed. Your changes have been retained.");
+}
+
+function selectRecovery(records: StoredDraftRecovery[]) {
+ const selected = records[0] ?? null;
+ const sources = records.filter(
+ (record) =>
+ selected !== null &&
+ sameContent(record.value.content, selected.value.content) &&
+ record.value.baseRevision === selected.value.baseRevision &&
+ JSON.stringify(record.value.submission) ===
+ JSON.stringify(selected.value.submission),
+ );
+ const alternatives = records.filter((record) => !sources.includes(record));
+ const buffer = selected ? { ...selected.value } : null;
+ if (buffer && alternatives.length > 0) buffer.blocked = "conflict";
+ return { buffer, sources, alternatives };
+}
+
+export class DraftResourceStore {
+ private readonly entries = new Map();
+ private readonly writerId = crypto.randomUUID();
+ private readonly listeners = new Set<() => void>();
+ private recoverable: readonly RecoverableDraftSnapshot[] = [];
+ private resumed = false;
+ private disposed = false;
+ private readonly unsubscribeCache: () => void;
+ private readonly writerReady: Promise;
+ private readonly releaseWriters = new Set<() => void>();
+
+ constructor(
+ private readonly queryClient: QueryClient,
+ private readonly api: DraftResourceApi,
+ private readonly storage: DraftRecoveryStorage,
+ private readonly debounceMs = 350,
+ ) {
+ this.writerReady = this.holdWriter(this.writerId, false).then(() => {});
+ const recovered = readDraftRecoveries(storage);
+ for (const id of new Set(
+ recovered.records.map((record) => record.value.id),
+ )) {
+ this.entry(id, true);
+ }
+ this.unsubscribeCache = queryClient.getQueryCache().subscribe((event) => {
+ if (
+ event.type !== "updated" ||
+ (event.action.type !== "success" && event.action.type !== "error")
+ )
+ return;
+ const key = event.query.queryKey;
+ if (
+ key[0] !== "drafts" ||
+ key[1] !== "detail" ||
+ typeof key[2] !== "string"
+ )
+ return;
+ const entry = this.entries.get(key[2]);
+ if (!entry || entry.busy) return;
+ this.reconcile(entry);
+ this.emit(entry);
+ });
+ if (typeof window !== "undefined") {
+ window.addEventListener("pagehide", this.persistAll);
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
+ window.addEventListener("storage", this.onStorage);
+ }
+ this.refreshRecoverable();
+ }
+
+ private holdWriter(writer: string, ifAvailable: boolean): Promise {
+ if (typeof navigator === "undefined" || !navigator.locks)
+ return Promise.resolve(true);
+ return new Promise((ready) => {
+ void navigator.locks
+ .request(
+ `${WRITER_LOCK_PREFIX}${writer}`,
+ { ifAvailable },
+ async (lock) => {
+ if (!lock || this.disposed) {
+ ready(false);
+ return;
+ }
+ const released = new Promise((resolve) => {
+ this.releaseWriters.add(resolve);
+ });
+ ready(true);
+ await released;
+ },
+ )
+ .catch(() => ready(false));
+ });
+ }
+
+ private entry(id: string, restoreRecovery = false): Entry {
+ draftIdSchema.parse(id);
+ const existing = this.entries.get(id);
+ if (existing) return existing;
+ const recovered = readDraftRecoveries(this.storage, id);
+ const { buffer, sources, alternatives } = selectRecovery(
+ restoreRecovery ? recovered.records : [],
+ );
+ const entry: Entry = {
+ id,
+ buffer,
+ ownsBuffer: false,
+ sources,
+ alternatives,
+ persistenceError: recovered.error,
+ error: null,
+ deleted: buffer?.blocked === "deleted",
+ busy: null,
+ timer: null,
+ listeners: new Set(),
+ snapshot: {
+ id,
+ content: null,
+ status: "loading",
+ error: null,
+ persistenceError: recovered.error,
+ recoveryCopies: [],
+ },
+ };
+ this.entries.set(id, entry);
+ this.reconcile(entry);
+ this.refreshSnapshot(entry);
+ return entry;
+ }
+
+ private remote(id: string): Draft | null | undefined {
+ return this.queryClient.getQueryData(
+ draftResourceQueryKey(id),
+ );
+ }
+
+ private refreshSnapshot(entry: Entry): boolean {
+ const remote = this.remote(entry.id);
+ const blocked = entry.buffer?.blocked;
+ const deleted = entry.deleted || entry.buffer?.deleteRequested === true;
+ const error =
+ entry.error ??
+ this.queryClient.getQueryState(
+ draftResourceQueryKey(entry.id),
+ )?.error ??
+ null;
+ const status: DraftResourceStatus =
+ blocked ??
+ (deleted
+ ? "deleted"
+ : error || entry.persistenceError
+ ? "error"
+ : entry.buffer
+ ? "saving"
+ : remote === undefined
+ ? "loading"
+ : "saved");
+ const next: DraftResourceSnapshot = {
+ id: entry.id,
+ content: deleted
+ ? EMPTY_CONTENT
+ : (entry.buffer?.content ?? remote?.content ?? null),
+ status,
+ error,
+ persistenceError: entry.persistenceError,
+ recoveryCopies: [
+ ...(blocked && entry.buffer ? [entry.buffer.content] : []),
+ ...(entry.buffer
+ ? entry.alternatives.map((record) => record.value.content)
+ : []),
+ ],
+ };
+ const previous = entry.snapshot;
+ if (
+ (previous.content === next.content ||
+ (previous.content !== null &&
+ next.content !== null &&
+ sameContent(previous.content, next.content))) &&
+ previous.status === next.status &&
+ previous.error === next.error &&
+ previous.persistenceError === next.persistenceError &&
+ previous.recoveryCopies.length === next.recoveryCopies.length &&
+ previous.recoveryCopies.every((content, index) =>
+ sameContent(content, next.recoveryCopies[index]!),
+ )
+ )
+ return false;
+ entry.snapshot = next;
+ return true;
+ }
+
+ private refreshRecoverable(): void {
+ const next = Array.from(this.entries.values()).flatMap(
+ (entry): RecoverableDraftSnapshot[] => {
+ const buffer = entry.buffer;
+ if (!buffer || isPromptDraftEmpty(buffer.content.prompt)) return [];
+ const status = entry.snapshot.status;
+ return [
+ {
+ id: entry.id,
+ content: buffer.content,
+ updatedAt: buffer.updatedAt,
+ status:
+ status === "loading" || status === "saved" ? "saving" : status,
+ error: entry.snapshot.error,
+ persistenceError: entry.persistenceError,
+ },
+ ];
+ },
+ );
+ if (
+ this.recoverable.length === next.length &&
+ this.recoverable.every((previous, index) => {
+ const current = next[index]!;
+ return (
+ previous.id === current.id &&
+ sameContent(previous.content, current.content) &&
+ previous.updatedAt === current.updatedAt &&
+ previous.status === current.status &&
+ previous.error === current.error &&
+ previous.persistenceError === current.persistenceError
+ );
+ })
+ )
+ return;
+ this.recoverable = next;
+ for (const listener of this.listeners) listener();
+ }
+
+ private emit(entry: Entry): void {
+ if (this.refreshSnapshot(entry)) {
+ for (const listener of entry.listeners) listener();
+ }
+ this.refreshRecoverable();
+ }
+
+ private persist(entry: Entry): void {
+ try {
+ const key = `${DRAFT_RECOVERY_PREFIX}${entry.id}:${this.writerId}`;
+ if (entry.buffer) {
+ if (!entry.ownsBuffer) return;
+ this.storage.setItem(key, JSON.stringify(entry.buffer));
+ } else {
+ this.storage.removeItem(key);
+ for (const source of entry.sources)
+ removeUnchangedRecovery(this.storage, source);
+ entry.sources = [];
+ entry.ownsBuffer = false;
+ }
+ entry.persistenceError = null;
+ } catch {
+ entry.persistenceError = new Error(
+ "Could not save browser recovery data. Keep this page open and retry saving.",
+ );
+ }
+ }
+
+ private persistAll = (): void => {
+ for (const entry of this.entries.values()) {
+ if (!entry.buffer) continue;
+ this.persist(entry);
+ this.emit(entry);
+ }
+ };
+
+ private onVisibilityChange = (): void => {
+ if (document.visibilityState === "hidden") this.persistAll();
+ };
+
+ private onStorage = (event: StorageEvent): void => {
+ if (!event.key?.startsWith(DRAFT_RECOVERY_PREFIX)) return;
+ const recovered = readDraftRecoveries(this.storage);
+ for (const entry of this.entries.values()) {
+ entry.alternatives = recovered.records.filter(
+ (record) =>
+ record.value.id === entry.id &&
+ !record.key.endsWith(`:${this.writerId}`) &&
+ !entry.sources.some(
+ (item) => item.key === record.key && item.raw === record.raw,
+ ) &&
+ (!entry.buffer ||
+ !sameContent(entry.buffer.content, record.value.content)),
+ );
+ this.emit(entry);
+ }
+ };
+
+ private cancelTimer(entry: Entry): void {
+ if (entry.timer !== null) clearTimeout(entry.timer);
+ entry.timer = null;
+ }
+
+ private schedule(entry: Entry): void {
+ this.cancelTimer(entry);
+ if (
+ entry.buffer?.blocked ||
+ entry.buffer?.submission ||
+ entry.buffer?.deleteRequested
+ )
+ return;
+ entry.timer = setTimeout(() => {
+ entry.timer = null;
+ void this.flush(entry.id).catch(() => {});
+ }, this.debounceMs);
+ }
+
+ private reconcile(entry: Entry): void {
+ if (entry.deleted) return;
+ const remote = this.remote(entry.id);
+ if (remote === undefined) return;
+ const buffer = entry.buffer;
+ if (!buffer) {
+ entry.deleted = remote === null;
+ return;
+ }
+ if (buffer.createContent || buffer.submission || buffer.blocked) return;
+ if (remote === null) {
+ entry.deleted = true;
+ buffer.blocked = "deleted";
+ this.cancelTimer(entry);
+ this.persist(entry);
+ } else if (remote.revision !== buffer.baseRevision) {
+ if (
+ sameContent(remote.content, buffer.content) &&
+ !buffer.forceRevision &&
+ !buffer.deleteRequested
+ ) {
+ entry.buffer = null;
+ } else {
+ buffer.blocked = "conflict";
+ this.cancelTimer(entry);
+ }
+ this.persist(entry);
+ }
+ }
+
+ getSnapshot = (id: string): DraftResourceSnapshot => this.entry(id).snapshot;
+
+ subscribe = (id: string, listener: () => void): (() => void) => {
+ const entry = this.entry(id);
+ entry.listeners.add(listener);
+ return () => {
+ entry.listeners.delete(listener);
+ };
+ };
+
+ getRecoverableSnapshot = (): readonly RecoverableDraftSnapshot[] =>
+ this.recoverable;
+
+ subscribeRecoverable = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ };
+
+ create(initial: DraftContentInput): string {
+ const id = `drf_${crypto.randomUUID()}`;
+ this.initialize(id, initial);
+ return id;
+ }
+
+ initialize(id: string, initial: DraftContentInput): boolean {
+ const content = draftContentSchema.parse(initial);
+ const entry = this.entry(id);
+ if (entry.buffer) {
+ this.persist(entry);
+ this.emit(entry);
+ return entry.persistenceError === null;
+ }
+ if (this.remote(id) !== undefined || entry.deleted) return true;
+ entry.buffer = {
+ version: 1,
+ id,
+ content,
+ createContent: content,
+ baseRevision: null,
+ submission: null,
+ blocked: null,
+ deleteRequested: false,
+ forceRevision: false,
+ updatedAt: Date.now(),
+ };
+ entry.ownsBuffer = true;
+ this.persist(entry);
+ this.emit(entry);
+ this.schedule(entry);
+ return entry.persistenceError === null;
+ }
+
+ async load(id: string): Promise {
+ const entry = this.entry(id);
+ try {
+ await this.queryClient.fetchQuery({
+ queryKey: draftResourceQueryKey(id),
+ queryFn: ({ signal }) => this.api.get(id, signal),
+ staleTime: 0,
+ });
+ entry.error = null;
+ if (!entry.busy) this.reconcile(entry);
+ this.emit(entry);
+ } catch (error) {
+ entry.error = asError(error);
+ this.emit(entry);
+ throw error;
+ }
+ }
+
+ edit(
+ id: string,
+ updater: (content: DraftContent) => DraftContentInput,
+ ): void {
+ const entry = this.entry(id);
+ if (entry.deleted || entry.buffer?.deleteRequested) return;
+ const content = entry.buffer?.content ?? this.remote(id)?.content;
+ if (!content)
+ throw new Error("Wait for this draft to load before editing.");
+ const next = draftContentSchema.parse(updater(content));
+ if (sameContent(content, next)) return;
+ entry.buffer = entry.buffer
+ ? { ...entry.buffer, content: next, updatedAt: Date.now() }
+ : {
+ version: 1,
+ id,
+ content: next,
+ createContent: null,
+ baseRevision: this.remote(id)?.revision ?? null,
+ submission: null,
+ blocked: null,
+ deleteRequested: false,
+ forceRevision: false,
+ updatedAt: Date.now(),
+ };
+ entry.error = null;
+ entry.ownsBuffer = true;
+ this.persist(entry);
+ this.emit(entry);
+ this.schedule(entry);
+ }
+
+ private async exclusive(entry: Entry, run: () => Promise): Promise {
+ while (entry.busy) await entry.busy.catch(() => {});
+ const task = Promise.resolve().then(run);
+ entry.busy = task;
+ this.emit(entry);
+ try {
+ return await task;
+ } catch (error) {
+ entry.error = asError(error);
+ if (error instanceof HttpError && entry.buffer) {
+ if (error.code === "draft_revision_conflict")
+ entry.buffer.blocked = "conflict";
+ if (
+ isDraftGoneError(error) &&
+ (!entry.buffer.submission || error.code === "draft_gone")
+ ) {
+ entry.buffer.submission = null;
+ entry.buffer.blocked = "deleted";
+ entry.deleted = true;
+ await this.setRemote(entry.id, null);
+ }
+ }
+ this.persist(entry);
+ throw error;
+ } finally {
+ entry.busy = null;
+ this.reconcile(entry);
+ this.emit(entry);
+ }
+ }
+
+ private async setRemote(id: string, draft: Draft | null): Promise {
+ if (await cacheDraftResource(this.queryClient, id, draft)) {
+ void invalidateDraftLists(this.queryClient);
+ }
+ }
+
+ private async save(entry: Entry, forDelete = false): Promise {
+ this.cancelTimer(entry);
+ if (entry.deleted || entry.buffer?.blocked)
+ throw new Error("This draft needs recovery before it can be saved.");
+ if (entry.buffer?.submission)
+ throw new Error(
+ "Retry the pending submission before saving newer edits.",
+ );
+ while (entry.buffer && (!entry.buffer.deleteRequested || forDelete)) {
+ const buffer = entry.buffer;
+ if (buffer.blocked)
+ throw new Error(
+ "This draft changed elsewhere. Reload it or save your edits as a copy.",
+ );
+ const creating = buffer.createContent !== null;
+ if (creating) {
+ const original = buffer.createContent;
+ if (original === null)
+ throw new Error("Missing initial draft contents.");
+ const response = await this.api.create(entry.id, original);
+ await this.setRemote(entry.id, response.draft);
+ if (!response.draft) {
+ if (entry.buffer) entry.buffer.blocked = "deleted";
+ entry.deleted = true;
+ this.persist(entry);
+ throw new Error(
+ "This draft was already consumed or deleted. Save your local edits as a new copy.",
+ );
+ }
+ const current = entry.buffer;
+ if (!current) throw new Error("Draft recovery data is unavailable.");
+ current.createContent = null;
+ current.baseRevision = response.draft.revision;
+ if (
+ !sameContent(response.draft.content, original) &&
+ !sameContent(response.draft.content, current.content)
+ ) {
+ current.blocked = "conflict";
+ this.persist(entry);
+ throw new Error(
+ "This draft was changed elsewhere. Your edits are available as a recovery copy.",
+ );
+ }
+ if (
+ sameContent(response.draft.content, current.content) &&
+ !current.forceRevision &&
+ !current.deleteRequested &&
+ !current.blocked
+ )
+ entry.buffer = null;
+ this.persist(entry);
+ if (forDelete) return response.draft;
+ if (current.deleteRequested) return response.draft;
+ continue;
+ }
+ const remote = this.remote(entry.id);
+ if (!remote || buffer.baseRevision === null) {
+ await this.load(entry.id);
+ this.reconcile(entry);
+ if (!this.remote(entry.id) || entry.buffer?.blocked)
+ throw new Error(
+ "This draft is no longer available. Recover your local edits as a copy.",
+ );
+ continue;
+ }
+ if (forDelete) return remote;
+ const submittedContent = buffer.content;
+ const saved = await this.api.update(
+ entry.id,
+ buffer.baseRevision,
+ submittedContent,
+ );
+ await this.setRemote(entry.id, saved);
+ const current = entry.buffer;
+ if (current) {
+ current.baseRevision = saved.revision;
+ current.forceRevision = false;
+ if (
+ sameContent(current.content, submittedContent) &&
+ !current.deleteRequested &&
+ !current.blocked
+ )
+ entry.buffer = null;
+ }
+ entry.error = null;
+ this.persist(entry);
+ this.emit(entry);
+ }
+ const remote = this.remote(entry.id);
+ if (!remote) throw new Error("This draft has not been saved.");
+ return remote;
+ }
+
+ flush(id: string): Promise {
+ const entry = this.entry(id);
+ entry.ownsBuffer = true;
+ return this.exclusive(entry, () => this.save(entry));
+ }
+
+ submit(id: string): Promise {
+ const entry = this.entry(id);
+ entry.ownsBuffer = true;
+ return this.exclusive(entry, async () => {
+ if (entry.buffer?.deleteRequested)
+ throw new Error("This draft is being deleted.");
+ if (!entry.buffer?.submission) {
+ const saved = await this.save(entry);
+ if (entry.buffer?.deleteRequested)
+ throw new Error("This draft is being deleted.");
+ entry.buffer = {
+ version: 1,
+ id,
+ content: saved.content,
+ baseRevision: saved.revision,
+ createContent: null,
+ submission: { revision: saved.revision, content: saved.content },
+ blocked: null,
+ deleteRequested: false,
+ forceRevision: false,
+ updatedAt: Date.now(),
+ };
+ entry.ownsBuffer = true;
+ this.persist(entry);
+ this.emit(entry);
+ }
+ const submitted = entry.buffer.submission;
+ if (!submitted) throw new Error("Missing draft submission revision.");
+ let result: DraftSubmitResponse;
+ try {
+ result = await this.api.submit(id, submitted.revision);
+ } catch (error) {
+ if (
+ error instanceof HttpError &&
+ error.status === 409 &&
+ error.code === "draft_revision_conflict" &&
+ entry.buffer
+ ) {
+ entry.buffer.submission = null;
+ }
+ if (
+ error instanceof HttpError &&
+ (error.code === "draft_submission_failed" ||
+ error.code === "draft_not_ready")
+ ) {
+ if (entry.buffer) {
+ entry.buffer.submission = null;
+ entry.buffer.forceRevision = true;
+ }
+ }
+ throw error;
+ }
+ await this.setRemote(id, result.draft);
+ const current = entry.buffer;
+ const hasNewerEdits =
+ current !== null && !sameContent(current.content, submitted.content);
+ let recoveryDraftId: string | null = null;
+ if (result.draft === null) {
+ if (hasNewerEdits && current && !current.deleteRequested)
+ recoveryDraftId = this.create(current.content);
+ if (
+ recoveryDraftId &&
+ this.entry(recoveryDraftId).persistenceError &&
+ current
+ ) {
+ current.submission = null;
+ current.blocked = "deleted";
+ } else {
+ entry.buffer = null;
+ }
+ entry.deleted = true;
+ } else if (hasNewerEdits && current) {
+ current.submission = null;
+ if (
+ result.draft.revision !== submitted.revision &&
+ !sameContent(result.draft.content, current.content)
+ ) {
+ current.blocked = "conflict";
+ } else {
+ current.baseRevision = result.draft.revision;
+ if (sameContent(result.draft.content, current.content))
+ entry.buffer = null;
+ }
+ } else {
+ entry.buffer = null;
+ }
+ entry.error = null;
+ this.persist(entry);
+ if (entry.buffer) this.schedule(entry);
+ return { ...result, recoveryDraftId };
+ });
+ }
+
+ async delete(id: string): Promise {
+ const entry = this.entry(id);
+ const content = entry.buffer?.content ?? this.remote(id)?.content;
+ if (!content) {
+ await this.load(id);
+ if (entry.deleted) return;
+ return this.delete(id);
+ }
+ entry.buffer = entry.buffer ?? {
+ version: 1,
+ id,
+ content,
+ baseRevision: this.remote(id)?.revision ?? null,
+ createContent: null,
+ submission: null,
+ blocked: null,
+ deleteRequested: false,
+ forceRevision: false,
+ updatedAt: Date.now(),
+ };
+ entry.buffer.deleteRequested = true;
+ entry.ownsBuffer = true;
+ this.cancelTimer(entry);
+ this.persist(entry);
+ this.emit(entry);
+ await this.exclusive(entry, async () => {
+ if (this.remote(id) === null && !entry.buffer?.createContent) {
+ entry.buffer = null;
+ entry.deleted = true;
+ this.persist(entry);
+ return;
+ }
+ const saved = await this.save(entry, true);
+ await this.api.delete(id, entry.buffer?.baseRevision ?? saved.revision);
+ entry.buffer = null;
+ entry.deleted = true;
+ entry.error = null;
+ await this.setRemote(id, null);
+ this.persist(entry);
+ });
+ }
+
+ async retry(id: string): Promise {
+ const entry = this.entry(id);
+ this.persist(entry);
+ if (entry.buffer?.submission) return this.submit(id);
+ else if (entry.buffer?.deleteRequested) await this.delete(id);
+ else if (entry.buffer) await this.flush(id);
+ else await this.load(id);
+ this.emit(entry);
+ return null;
+ }
+
+ async reloadRemote(id: string): Promise {
+ const entry = this.entry(id);
+ await this.exclusive(entry, async () => {
+ if (entry.buffer?.submission)
+ throw new Error(
+ "Retry the pending submission before discarding recovery data.",
+ );
+ await this.load(id);
+ this.cancelTimer(entry);
+ entry.buffer = null;
+ for (const alternative of entry.alternatives)
+ removeUnchangedRecovery(this.storage, alternative);
+ entry.alternatives = [];
+ entry.deleted = this.remote(id) === null;
+ entry.error = null;
+ this.persist(entry);
+ });
+ }
+
+ saveLocalAsCopy(id: string, index = 0): string {
+ const entry = this.entry(id);
+ const content =
+ entry.snapshot.recoveryCopies[index] ?? entry.buffer?.content;
+ if (!content) throw new Error("There are no local edits to recover.");
+ return this.create(content);
+ }
+
+ resumeRecoveries = (): void => {
+ if (this.resumed) return;
+ this.resumed = true;
+ void this.resumeAbandonedRecoveries();
+ };
+
+ private async resumeAbandonedRecoveries(): Promise {
+ await this.writerReady;
+ if (this.disposed) return;
+ const writers = new Set(
+ readDraftRecoveries(this.storage).records.map((record) =>
+ record.key.slice(record.key.lastIndexOf(":") + 1),
+ ),
+ );
+ const claimedWriters = new Set(
+ (
+ await Promise.all(
+ Array.from(writers, async (writer) =>
+ (await this.holdWriter(writer, true)) ? [writer] : [],
+ ),
+ )
+ ).flat(),
+ );
+ if (this.disposed) return;
+ for (const entry of this.entries.values()) {
+ if (entry.buffer && !entry.ownsBuffer) {
+ const recovered = readDraftRecoveries(this.storage, entry.id);
+ const abandoned = recovered.records.filter((record) => {
+ const writer = record.key.slice(record.key.lastIndexOf(":") + 1);
+ return claimedWriters.has(writer);
+ });
+ const selected = selectRecovery(abandoned);
+ entry.buffer = selected.buffer;
+ entry.sources = selected.sources;
+ entry.alternatives = selected.alternatives;
+ entry.ownsBuffer = entry.buffer !== null;
+ entry.deleted = entry.buffer?.blocked === "deleted";
+ entry.persistenceError = recovered.error;
+ this.persist(entry);
+ this.reconcile(entry);
+ this.emit(entry);
+ }
+ if (
+ !entry.buffer ||
+ entry.buffer.blocked ||
+ entry.buffer.submission ||
+ entry.buffer.deleteRequested
+ )
+ continue;
+ void this.load(entry.id)
+ .then(() => {
+ if (!entry.buffer?.blocked) this.schedule(entry);
+ })
+ .catch(() => {});
+ }
+ }
+
+ dispose(): void {
+ if (this.disposed) return;
+ this.disposed = true;
+ for (const release of this.releaseWriters) release();
+ this.releaseWriters.clear();
+ this.persistAll();
+ this.unsubscribeCache();
+ for (const entry of this.entries.values()) this.cancelTimer(entry);
+ if (typeof window !== "undefined") {
+ window.removeEventListener("pagehide", this.persistAll);
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
+ window.removeEventListener("storage", this.onStorage);
+ }
+ }
+}
diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts
index dfdbe5254f7..4d58e21b1b7 100644
--- a/apps/app/src/lib/plugin-sdk-hooks.ts
+++ b/apps/app/src/lib/plugin-sdk-hooks.ts
@@ -1,3 +1,4 @@
+import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft";
import {
removePluginMention,
subscribeComposerSubmitted,
@@ -58,7 +59,6 @@ import {
AUTOMATIONS_PLUGIN_ID,
getPluginPanelRoutePath,
getProjectComposeRoutePath,
- getRootComposeRoutePath,
getThreadRoutePath,
AUTOMATION_EDIT_ROUTE_PATH,
} from "@/lib/route-paths";
@@ -295,6 +295,7 @@ export function useBbNavigate(): BbNavigate {
const location = useLocation();
const openThreadPanelHandler = usePluginThreadPanelOpenHandler();
const navigate = useNavigate();
+ const openNewDraft = useOpenNewThreadDraft();
const appNavigation = useAppNavigationHost();
const toThread = useCallback(
(threadId: string) => {
@@ -331,18 +332,21 @@ export function useBbNavigate(): BbNavigate {
const replacesAutomationEditRoute =
pluginId === AUTOMATIONS_PLUGIN_ID &&
isAutomationEditRoutePath(location.pathname);
- void navigate(getRootComposeRoutePath(), {
- ...(replacesAutomationEditRoute ? { replace: true } : {}),
- state: {
- focusPrompt: options?.focusPrompt ?? false,
- initialPrompt: options?.initialPrompt ?? "",
- ...(replacesAutomationEditRoute
- ? { replaceInitialPrompt: true }
- : {}),
+ void openNewDraft(
+ {},
+ {
+ ...(replacesAutomationEditRoute ? { replace: true } : {}),
+ state: {
+ focusPrompt: options?.focusPrompt ?? false,
+ initialPrompt: options?.initialPrompt ?? "",
+ ...(replacesAutomationEditRoute
+ ? { replaceInitialPrompt: true }
+ : {}),
+ },
},
- });
+ );
},
- [location.pathname, navigate, pluginId],
+ [location.pathname, openNewDraft, pluginId],
);
const openThreadPanel = useCallback(
(options) => openThreadPanelHandler?.({ ...options, pluginId }) ?? false,
diff --git a/apps/app/src/lib/plugin-sidebar-hooks.test.tsx b/apps/app/src/lib/plugin-sidebar-hooks.test.tsx
index 14553ccb0b3..a17e881e9ef 100644
--- a/apps/app/src/lib/plugin-sidebar-hooks.test.tsx
+++ b/apps/app/src/lib/plugin-sidebar-hooks.test.tsx
@@ -9,6 +9,11 @@ import {
useSidebarThreads,
} from "./plugin-sidebar-hooks";
+vi.mock("@/lib/drafts/resource-runtime", () => ({
+ createNewThreadDraft: () => "drf_fresh_navigation",
+ initializeNewThreadDraft: vi.fn(),
+}));
+
const actions = vi.hoisted(() => ({
navigate: vi.fn(),
setRootComposeProjectId: vi.fn(),
@@ -59,6 +64,7 @@ vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({
}));
vi.mock("./root-compose-selection", () => ({
+ useRootComposeProjectId: () => ["proj_saved", vi.fn()],
useSetRootComposeProjectId: () => actions.setRootComposeProjectId,
}));
@@ -129,8 +135,11 @@ describe("useSidebarThreadActions", () => {
});
expect(actions.setRootComposeProjectId).toHaveBeenCalledWith("proj_target");
- expect(actions.navigate).toHaveBeenCalledWith("/", {
- state: { focusPrompt: true },
- });
+ expect(actions.navigate).toHaveBeenCalledWith(
+ "/?draft=drf_fresh_navigation",
+ {
+ state: { focusPrompt: true },
+ },
+ );
});
});
diff --git a/apps/app/src/lib/plugin-sidebar-hooks.ts b/apps/app/src/lib/plugin-sidebar-hooks.ts
index 752082075e1..06735dbaaae 100644
--- a/apps/app/src/lib/plugin-sidebar-hooks.ts
+++ b/apps/app/src/lib/plugin-sidebar-hooks.ts
@@ -1,3 +1,4 @@
+import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft";
import { useCallback, useMemo } from "react";
import { useStore } from "jotai";
import {
@@ -25,7 +26,7 @@ import { useRouteNavigate } from "@/components/ui/app-route-anchor";
import { toPluginSidebarThread } from "./plugin-sidebar-threads";
import { useSetRootComposeProjectId } from "./root-compose-selection";
import { openThreadInSplit } from "./split-layout/openThreadInSplit";
-import { getRootComposeRoutePath, getThreadRoutePath } from "./route-paths";
+import { getThreadRoutePath } from "./route-paths";
const EMPTY_THREADS: readonly PluginSidebarThread[] = [];
const EMPTY_PROJECTS: readonly PluginSidebarProject[] = [];
@@ -117,6 +118,7 @@ export function useSidebarThreadEntry(
export function useSidebarThreadActions(): PluginSidebarThreadActions {
const navigate = useRouteNavigate();
+ const openNewDraft = useOpenNewThreadDraft();
const store = useStore();
const isCompact = useIsCompactViewport();
const setRootComposeProjectId = useSetRootComposeProjectId();
@@ -159,7 +161,10 @@ export function useSidebarThreadActions(): PluginSidebarThreadActions {
setRootComposeProjectId(projectId);
}
const state = options?.focusPrompt ? { focusPrompt: true } : undefined;
- navigate(getRootComposeRoutePath(), state ? { state } : undefined);
+ openNewDraft(
+ projectId === undefined ? {} : { projectId },
+ state ? { state } : undefined,
+ );
},
async setPinned(threadId, pinned) {
const entry = requireEntry(threadId);
@@ -187,6 +192,7 @@ export function useSidebarThreadActions(): PluginSidebarThreadActions {
hostActions,
isCompact,
navigate,
+ openNewDraft,
requireEntry,
setRootComposeProjectId,
store,
diff --git a/apps/app/src/lib/root-compose-selection.ts b/apps/app/src/lib/root-compose-selection.ts
index 1d3680e8141..f81378a2c15 100644
--- a/apps/app/src/lib/root-compose-selection.ts
+++ b/apps/app/src/lib/root-compose-selection.ts
@@ -20,7 +20,7 @@ const rootComposeProjectIdStorage = createTabScopedStorage(
{ persistInitialValue: true },
);
-const rootComposeProjectIdAtom = atomWithStorage(
+export const rootComposeProjectIdAtom = atomWithStorage(
ROOT_COMPOSE_PROJECT_ID_STORAGE_KEY,
PERSONAL_PROJECT_ID,
rootComposeProjectIdStorage,
diff --git a/apps/app/src/lib/split-layout/atoms.test.ts b/apps/app/src/lib/split-layout/atoms.test.ts
index 07279edce85..f82efaaed4c 100644
--- a/apps/app/src/lib/split-layout/atoms.test.ts
+++ b/apps/app/src/lib/split-layout/atoms.test.ts
@@ -1,17 +1,29 @@
// @vitest-environment jsdom
import { createStore } from "jotai";
-import { afterEach, describe, expect, it } from "vitest";
+import { initializeNewThreadDraft } from "@/lib/drafts/resource-runtime";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
closePanesForThreadsAtom,
maximizedPaneIdAtom,
MAXIMIZED_PANE_STORAGE_KEY,
splitLayoutAtom,
} from "./atoms";
-import { countPanes, findPaneByThread, splitPane } from "./ops";
-import { serializeSplitLayout, SPLIT_LAYOUT_STORAGE_KEY } from "./persistence";
+import { countPanes, findPaneByThread, listPanes, splitPane } from "./ops";
+import {
+ createSplitLayoutStorage,
+ deserializeSplitLayout,
+ serializeSplitLayout,
+ serializeLegacySplitLayout,
+ SPLIT_LAYOUT_STORAGE_KEY,
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+} from "./persistence";
import type { SplitLayout } from "./types";
+vi.mock("@/lib/drafts/resource-runtime", () => ({
+ initializeNewThreadDraft: vi.fn(() => true),
+}));
+
function singlePane(threadId: string): SplitLayout {
return {
root: {
@@ -32,6 +44,7 @@ function twoPanes(): SplitLayout {
}
afterEach(() => {
+ vi.clearAllMocks();
window.localStorage.clear();
window.sessionStorage.clear();
});
@@ -180,13 +193,159 @@ describe("closePanesForThreadsAtom", () => {
expect(store.set(closePanesForThreadsAtom, ["thread-1"])).toEqual({
removedAny: false,
focusedRoute: null,
+ focusedContent: null,
});
store.set(splitLayoutAtom, twoPanes());
expect(store.set(closePanesForThreadsAtom, [])).toEqual({
removedAny: false,
focusedRoute: null,
+ focusedContent: null,
});
expect(countPanes(store.get(splitLayoutAtom)!.root)).toBe(2);
});
});
+
+describe("draft layout migration", () => {
+ it("keeps legacy panes and distinct migrated draft IDs across reloads", () => {
+ const previous = {
+ version: 1,
+ layout: {
+ root: {
+ type: "split",
+ dir: "row",
+ sizes: [0.25, 0.25, 0.25, 0.25],
+ children: [
+ { type: "pane", paneId: "pane-1", content: { kind: "new-thread" } },
+ { type: "pane", paneId: "pane-2", content: { kind: "new-thread" } },
+ {
+ type: "pane",
+ paneId: "pane-3",
+ content: { kind: "plugin-detail", pluginId: "notes" },
+ },
+ {
+ type: "pane",
+ paneId: "pane-4",
+ content: { kind: "thread", projectId: "p1", threadId: "t1" },
+ },
+ ],
+ },
+ focusedPaneId: "pane-2",
+ },
+ };
+ window.sessionStorage.setItem(
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+ JSON.stringify(previous),
+ );
+ const storage = createSplitLayoutStorage();
+ const migrated = storage.getItem(SPLIT_LAYOUT_STORAGE_KEY, null)!;
+ const panes = listPanes(migrated.root);
+ expect(migrated.focusedPaneId).toBe("pane-2");
+ expect(panes[0]!.content).toMatchObject({
+ kind: "new-thread",
+ draftId: expect.stringMatching(/^drf_/),
+ });
+ expect(panes[0]!.content).not.toEqual(panes[1]!.content);
+ expect(panes.slice(2)).toEqual(previous.layout.root.children.slice(2));
+ expect(storage.getItem(SPLIT_LAYOUT_STORAGE_KEY, null)).toEqual(migrated);
+ expect(
+ JSON.parse(
+ window.sessionStorage.getItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY)!,
+ ),
+ ).toEqual(previous);
+ });
+
+ it("preserves malformed values on read and the old projection when a new write fails", () => {
+ const storage = createSplitLayoutStorage();
+ window.sessionStorage.setItem(
+ SPLIT_LAYOUT_STORAGE_KEY,
+ "malformed current",
+ );
+ window.localStorage.setItem(
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+ "malformed old",
+ );
+ expect(storage.getItem(SPLIT_LAYOUT_STORAGE_KEY, null)).toBeNull();
+ expect(initializeNewThreadDraft).not.toHaveBeenCalled();
+ expect(window.sessionStorage.getItem(SPLIT_LAYOUT_STORAGE_KEY)).toBe(
+ "malformed current",
+ );
+ expect(window.localStorage.getItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY)).toBe(
+ "malformed old",
+ );
+ const original = Storage.prototype.setItem;
+ const spy = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(function (this: Storage, key: string, value: string) {
+ if (key === SPLIT_LAYOUT_STORAGE_KEY) throw new Error("quota");
+ return original.call(this, key, value);
+ });
+ try {
+ storage.setItem(SPLIT_LAYOUT_STORAGE_KEY, twoPanes());
+ expect(window.localStorage.getItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY)).toBe(
+ "malformed old",
+ );
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it("retains legacy layout when the draft recovery checkpoint cannot be written", () => {
+ const previous = JSON.stringify({
+ version: 1,
+ layout: {
+ root: {
+ type: "pane",
+ paneId: "pane-1",
+ content: { kind: "new-thread" },
+ },
+ focusedPaneId: "pane-1",
+ },
+ });
+ window.sessionStorage.setItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY, previous);
+ vi.mocked(initializeNewThreadDraft)
+ .mockReturnValueOnce(false)
+ .mockReturnValueOnce(false);
+ const storage = createSplitLayoutStorage();
+ const migrated = storage.getItem(SPLIT_LAYOUT_STORAGE_KEY, null);
+ expect(storage.getItem(SPLIT_LAYOUT_STORAGE_KEY, null)).toBe(migrated);
+ storage.setItem(SPLIT_LAYOUT_STORAGE_KEY, migrated);
+ expect(window.sessionStorage.getItem(SPLIT_LAYOUT_STORAGE_KEY)).toBeNull();
+ expect(window.sessionStorage.getItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY)).toBe(
+ previous,
+ );
+ });
+
+ it("writes a rollback-readable projection after persisting the draft identity", () => {
+ const layout = splitPane(singlePane("thread-1"), "pane-1", "right", {
+ kind: "new-thread",
+ draftId: "drf_persisted_identity",
+ });
+ createSplitLayoutStorage().setItem(SPLIT_LAYOUT_STORAGE_KEY, layout);
+ expect(
+ deserializeSplitLayout(
+ window.sessionStorage.getItem(SPLIT_LAYOUT_STORAGE_KEY),
+ ),
+ ).toEqual(layout);
+ expect(window.sessionStorage.getItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY)).toBe(
+ serializeLegacySplitLayout(layout),
+ );
+ });
+
+ it("keeps the draft survivor when archiving the last thread pane", () => {
+ const store = createStore();
+ const content = {
+ kind: "new-thread",
+ draftId: "drf_surviving_draft",
+ } as const;
+ store.set(
+ splitLayoutAtom,
+ splitPane(singlePane("thread-1"), "pane-1", "right", content),
+ );
+ const result = store.set(closePanesForThreadsAtom, ["thread-1"]);
+ expect(result.focusedContent).toEqual(content);
+ expect(
+ listPanes(store.get(splitLayoutAtom)!.root).map((pane) => pane.content),
+ ).toEqual([content]);
+ });
+});
diff --git a/apps/app/src/lib/split-layout/atoms.ts b/apps/app/src/lib/split-layout/atoms.ts
index 8c8de114d6a..fe252955e6c 100644
--- a/apps/app/src/lib/split-layout/atoms.ts
+++ b/apps/app/src/lib/split-layout/atoms.ts
@@ -3,23 +3,14 @@ import { atomWithStorage } from "jotai/utils";
import {
createBooleanPreferenceAtom,
createTabScopedStorage,
- type SyncStorage,
} from "@/lib/browser-storage";
import type { ThreadRoutePathArgs } from "@/lib/route-paths";
import { findPane, listPanes, removePane } from "./ops";
import {
- deserializeSplitLayout,
- serializeSplitLayout,
+ createSplitLayoutStorage,
SPLIT_LAYOUT_STORAGE_KEY,
} from "./persistence";
-import type { SplitLayout } from "./types";
-
-function createSplitLayoutStorage(): SyncStorage {
- return createTabScopedStorage({
- parse: (storedValue) => deserializeSplitLayout(storedValue),
- serialize: (value) => (value === null ? "" : serializeSplitLayout(value)),
- });
-}
+import type { PaneContent, SplitLayout } from "./types";
export const splitLayoutAtom = atomWithStorage(
SPLIT_LAYOUT_STORAGE_KEY,
@@ -54,6 +45,7 @@ export const dimInactiveSplitsAtom = createBooleanPreferenceAtom(
export interface ClosePanesForThreadsResult {
removedAny: boolean;
focusedRoute: ThreadRoutePathArgs | null;
+ focusedContent: PaneContent | null;
}
export const closePanesForThreadsAtom = atom(
@@ -61,7 +53,7 @@ export const closePanesForThreadsAtom = atom(
(get, set, threadIds: readonly string[]): ClosePanesForThreadsResult => {
const current = get(splitLayoutAtom);
if (current === null || threadIds.length === 0) {
- return { removedAny: false, focusedRoute: null };
+ return { removedAny: false, focusedRoute: null, focusedContent: null };
}
const targets = new Set(threadIds);
let layout = current;
@@ -83,7 +75,7 @@ export const closePanesForThreadsAtom = atom(
removedAny = true;
}
if (!removedAny) {
- return { removedAny: false, focusedRoute: null };
+ return { removedAny: false, focusedRoute: null, focusedContent: null };
}
const maximizedPaneId = get(maximizedPaneIdAtom);
if (
@@ -103,12 +95,20 @@ export const closePanesForThreadsAtom = atom(
threadId: focused.content.threadId,
}
: null;
- if (survivorRoute === null) {
+ if (
+ focused === null ||
+ (focused.content.kind === "thread" &&
+ targets.has(focused.content.threadId))
+ ) {
set(splitLayoutAtom, null);
set(maximizedPaneIdAtom, null);
- return { removedAny: true, focusedRoute: null };
+ return { removedAny: true, focusedRoute: null, focusedContent: null };
}
set(splitLayoutAtom, layout);
- return { removedAny: true, focusedRoute: survivorRoute };
+ return {
+ removedAny: true,
+ focusedRoute: survivorRoute,
+ focusedContent: focused.content,
+ };
},
);
diff --git a/apps/app/src/lib/split-layout/openDraftInSplit.test.ts b/apps/app/src/lib/split-layout/openDraftInSplit.test.ts
new file mode 100644
index 00000000000..88db1d0fcaf
--- /dev/null
+++ b/apps/app/src/lib/split-layout/openDraftInSplit.test.ts
@@ -0,0 +1,118 @@
+import { createStore } from "jotai";
+import { describe, expect, it, vi } from "vitest";
+import { splitLayoutAtom } from "./atoms";
+import { openDraftInSplit } from "./openDraftInSplit";
+import {
+ findPaneByContent,
+ listPanes,
+ replaceDraftPaneContent,
+ setFocus,
+ splitPane,
+} from "./ops";
+import { createSinglePaneLayout } from "@/views/thread-detail/splitThreadNavigation";
+import type { ThreadOpenSplit } from "@bb/server-contract";
+
+const first = { kind: "new-thread", draftId: "drf_first_draft" } as const;
+const second = { kind: "new-thread", draftId: "drf_second_draft" } as const;
+const thread = { kind: "thread", projectId: "p1", threadId: "t1" } as const;
+
+function setup() {
+ const store = createStore();
+ const layout = splitPane(
+ createSinglePaneLayout(thread),
+ "pane-1",
+ "right",
+ first,
+ );
+ store.set(splitLayoutAtom, layout);
+ return {
+ store,
+ navigate: vi.fn(),
+ isCompact: false,
+ draftId: second.draftId,
+ };
+}
+
+describe("draft split navigation", () => {
+ it("opens independent drafts beside the current draft and focuses the saved pane on repeat", () => {
+ const args = setup();
+ openDraftInSplit(args);
+ const opened = args.store.get(splitLayoutAtom)!;
+ expect(listPanes(opened.root).map((pane) => pane.content)).toEqual([
+ thread,
+ first,
+ second,
+ ]);
+ openDraftInSplit({ ...args, draftId: first.draftId });
+ const focused = args.store.get(splitLayoutAtom)!;
+ expect(listPanes(focused.root)).toHaveLength(3);
+ expect(focused.focusedPaneId).toBe(
+ findPaneByContent(focused.root, first)!.paneId,
+ );
+ expect(args.navigate).toHaveBeenLastCalledWith("/?draft=drf_first_draft", {
+ replace: true,
+ });
+ });
+
+ it.each(["right", "down", "left", "top", "replace"])(
+ "honors public %s placement",
+ (split) => {
+ const args = setup();
+ openDraftInSplit({ ...args, split });
+ const panes = listPanes(args.store.get(splitLayoutAtom)!.root);
+ expect(panes).toHaveLength(split === "replace" ? 2 : 3);
+ const firstIndex = panes.findIndex(
+ (pane) =>
+ pane.content.kind === "new-thread" &&
+ pane.content.draftId === first.draftId,
+ );
+ const secondIndex = panes.findIndex(
+ (pane) =>
+ pane.content.kind === "new-thread" &&
+ pane.content.draftId === second.draftId,
+ );
+ if (split !== "replace")
+ expect(secondIndex < firstIndex).toBe(
+ split === "left" || split === "top",
+ );
+ },
+ );
+
+ it("replaces the focused pane at the pane limit and in compact mode", () => {
+ for (const isCompact of [false, true]) {
+ const args = setup();
+ for (let i = 2; i < 8; i += 1) {
+ const layout = args.store.get(splitLayoutAtom)!;
+ args.store.set(
+ splitLayoutAtom,
+ splitPane(layout, layout.focusedPaneId, "right", {
+ kind: "new-thread",
+ draftId: `drf_filler_${i}`,
+ }),
+ );
+ }
+ openDraftInSplit({ ...args, isCompact });
+ expect(listPanes(args.store.get(splitLayoutAtom)!.root)).toHaveLength(8);
+ }
+ });
+
+ it("updates a submitted draft's origin without moving focus or replacing a reused pane", () => {
+ const args = setup();
+ const origin = args.store.get(splitLayoutAtom)!;
+ const unfocused = setFocus(origin, "pane-1");
+ const replaced = replaceDraftPaneContent(
+ unfocused,
+ "pane-2",
+ first.draftId,
+ second,
+ )!;
+ expect(replaced.focusedPaneId).toBe("pane-1");
+ expect(findPaneByContent(replaced.root, second)?.paneId).toBe("pane-2");
+ expect(
+ replaceDraftPaneContent(replaced, "pane-2", first.draftId, thread),
+ ).toBe(replaced);
+ expect(
+ replaceDraftPaneContent(unfocused, "missing", first.draftId, thread),
+ ).toBe(unfocused);
+ });
+});
diff --git a/apps/app/src/lib/split-layout/openDraftInSplit.ts b/apps/app/src/lib/split-layout/openDraftInSplit.ts
new file mode 100644
index 00000000000..09368fa80bd
--- /dev/null
+++ b/apps/app/src/lib/split-layout/openDraftInSplit.ts
@@ -0,0 +1,57 @@
+import type { ThreadOpenSplit } from "@bb/server-contract";
+import { getDraftRoutePath } from "@/lib/draft-route";
+import { splitLayoutAtom } from "./atoms";
+import {
+ countPanes,
+ findPaneByContent,
+ MAX_PANES,
+ replacePaneContent,
+ setFocus,
+ splitPane,
+} from "./ops";
+import type { PaneContent, SplitLayout } from "./types";
+
+interface OpenDraftInSplitArgs {
+ store: {
+ get(atom: typeof splitLayoutAtom): SplitLayout | null;
+ set(atom: typeof splitLayoutAtom, value: SplitLayout): void;
+ };
+ navigate: (
+ route: string,
+ options?: { replace?: boolean },
+ ) => void | Promise;
+ draftId: string;
+ isCompact: boolean;
+ split?: ThreadOpenSplit;
+}
+
+export function openDraftInSplit({
+ store,
+ navigate,
+ draftId,
+ isCompact,
+ split = "right",
+}: OpenDraftInSplitArgs): void {
+ const route = getDraftRoutePath(draftId);
+ const layout = store.get(splitLayoutAtom);
+ const content: PaneContent = { kind: "new-thread", draftId };
+ const existing =
+ layout === null ? null : findPaneByContent(layout.root, content);
+ if (layout !== null) {
+ const next =
+ existing !== null
+ ? setFocus(layout, existing.paneId)
+ : isCompact ||
+ split === "replace" ||
+ countPanes(layout.root) >= MAX_PANES
+ ? replacePaneContent(layout, layout.focusedPaneId, content)
+ : splitPane(
+ layout,
+ layout.focusedPaneId,
+ split === "down" ? "bottom" : split,
+ content,
+ );
+ if (next !== layout) store.set(splitLayoutAtom, next);
+ }
+ void navigate(route, existing !== null ? { replace: true } : undefined);
+}
diff --git a/apps/app/src/lib/split-layout/ops.ts b/apps/app/src/lib/split-layout/ops.ts
index b888b64ea39..c10c3d3a144 100644
--- a/apps/app/src/lib/split-layout/ops.ts
+++ b/apps/app/src/lib/split-layout/ops.ts
@@ -68,7 +68,12 @@ export function findPaneByContent(
listPanes(root).find((pane) => {
const candidate = pane.content;
if (candidate.kind !== content.kind) return false;
- if (content.kind === "new-thread") return true;
+ if (content.kind === "new-thread") {
+ return (
+ candidate.kind === "new-thread" &&
+ candidate.draftId === content.draftId
+ );
+ }
if (content.kind === "thread") {
return (
candidate.kind === "thread" &&
@@ -207,6 +212,20 @@ export function replacePaneContent(
};
}
+export function replaceDraftPaneContent(
+ layout: SplitLayout | null,
+ paneId: string,
+ draftId: string,
+ content: PaneContent,
+): SplitLayout | null {
+ if (layout === null) return null;
+ const pane = findPane(layout.root, paneId);
+ if (pane?.content.kind !== "new-thread" || pane.content.draftId !== draftId)
+ return layout;
+ const next = replacePaneContent(layout, paneId, content);
+ return { ...next, focusedPaneId: layout.focusedPaneId };
+}
+
interface DetachResult {
node: LayoutNode | null;
detached: PaneNode | null;
diff --git a/apps/app/src/lib/split-layout/persistence.test.ts b/apps/app/src/lib/split-layout/persistence.test.ts
index bd796215d40..17d880abbea 100644
--- a/apps/app/src/lib/split-layout/persistence.test.ts
+++ b/apps/app/src/lib/split-layout/persistence.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
deserializeSplitLayout,
+ deserializeLegacySplitLayout,
serializeSplitLayout,
SPLIT_LAYOUT_SCHEMA_VERSION,
} from "./persistence";
@@ -75,7 +76,7 @@ describe("split layout persistence", () => {
{
type: "pane",
paneId: "pane-1",
- content: { kind: "new-thread" },
+ content: { kind: "new-thread", draftId: "drf_navigation_test" },
},
{
type: "pane",
@@ -103,6 +104,24 @@ describe("split layout persistence", () => {
);
});
+ it("rejects draft identities injected into a legacy singleton instead of initializing them", () => {
+ expect(
+ deserializeLegacySplitLayout(
+ JSON.stringify({
+ version: 1,
+ layout: {
+ root: {
+ type: "pane",
+ paneId: "pane-1",
+ content: { kind: "new-thread", draftId: "drf_consumed_identity" },
+ },
+ focusedPaneId: "pane-1",
+ },
+ }),
+ ),
+ ).toBeNull();
+ });
+
it("rejects malformed JSON, unknown versions, and invalid layout invariants", () => {
expect(deserializeSplitLayout(null)).toBeNull();
expect(deserializeSplitLayout("not json")).toBeNull();
diff --git a/apps/app/src/lib/split-layout/persistence.ts b/apps/app/src/lib/split-layout/persistence.ts
index 8a03f700bf8..71339b67210 100644
--- a/apps/app/src/lib/split-layout/persistence.ts
+++ b/apps/app/src/lib/split-layout/persistence.ts
@@ -1,9 +1,14 @@
+import { initializeNewThreadDraft } from "@/lib/drafts/resource-runtime";
+import { draftIdSchema } from "@bb/server-contract";
+import type { SyncStorage } from "@/lib/browser-storage";
import { z } from "zod";
import { MAX_PANES, countPanes, listPanes } from "./ops";
import type { LayoutNode, PaneNode, SplitLayout, SplitNode } from "./types";
-export const SPLIT_LAYOUT_SCHEMA_VERSION = 1;
-export const SPLIT_LAYOUT_STORAGE_KEY = "bb.splitLayout";
+export const SPLIT_LAYOUT_SCHEMA_VERSION = 2;
+export const SPLIT_LAYOUT_STORAGE_KEY = "bb.splitLayout.v2";
+
+export const LEGACY_SPLIT_LAYOUT_STORAGE_KEY = "bb.splitLayout";
const paneContentSchema = z.discriminatedUnion("kind", [
z
@@ -13,7 +18,7 @@ const paneContentSchema = z.discriminatedUnion("kind", [
threadId: z.string().min(1),
})
.strict(),
- z.object({ kind: z.literal("new-thread") }).strict(),
+ z.object({ kind: z.literal("new-thread"), draftId: draftIdSchema }).strict(),
z
.object({
kind: z.literal("plugin-panel"),
@@ -124,3 +129,182 @@ export function deserializeSplitLayout(
return null;
}
}
+
+function migrateLegacyNode(value: unknown): unknown {
+ if (typeof value !== "object" || value === null) return value;
+ if (!("type" in value)) return value;
+ if (value.type === "pane" && "content" in value) {
+ const content = value.content;
+ if (
+ typeof content === "object" &&
+ content !== null &&
+ "kind" in content &&
+ content.kind === "new-thread"
+ ) {
+ if (Object.keys(content).length !== 1) return { ...value, content: null };
+ return {
+ ...value,
+ content: { kind: "new-thread", draftId: `drf_${crypto.randomUUID()}` },
+ };
+ }
+ }
+ if (
+ value.type === "split" &&
+ "children" in value &&
+ Array.isArray(value.children)
+ ) {
+ return { ...value, children: value.children.map(migrateLegacyNode) };
+ }
+ return value;
+}
+
+export function deserializeLegacySplitLayout(
+ storedValue: string | null,
+): SplitLayout | null {
+ if (storedValue === null) return null;
+ try {
+ const parsed: unknown = JSON.parse(storedValue);
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ !("version" in parsed) ||
+ parsed.version !== 1 ||
+ !("layout" in parsed) ||
+ Object.keys(parsed).length !== 2
+ )
+ return null;
+ const layout = parsed.layout;
+ if (typeof layout !== "object" || layout === null || !("root" in layout))
+ return null;
+ const result = splitLayoutSchema.safeParse({
+ ...layout,
+ root: migrateLegacyNode(layout.root),
+ });
+ return result.success ? result.data : null;
+ } catch {
+ return null;
+ }
+}
+
+function legacyNode(node: LayoutNode): unknown {
+ if (node.type === "split")
+ return { ...node, children: node.children.map(legacyNode) };
+ return node.content.kind === "new-thread"
+ ? { ...node, content: { kind: "new-thread" } }
+ : node;
+}
+
+export function serializeLegacySplitLayout(layout: SplitLayout): string {
+ return JSON.stringify({
+ version: 1,
+ layout: { ...layout, root: legacyNode(layout.root) },
+ });
+}
+
+function browserStorage(
+ name: "sessionStorage" | "localStorage",
+): Storage | null {
+ try {
+ return typeof window === "undefined" ? null : window[name];
+ } catch {
+ return null;
+ }
+}
+
+function readStoredValue(storage: Storage | null, key: string): string | null {
+ try {
+ return storage?.getItem(key) ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function writeLayout(
+ storage: Storage | null,
+ key: string,
+ value: SplitLayout | null,
+): boolean {
+ if (storage === null) return false;
+ try {
+ storage.setItem(key, value === null ? "" : serializeSplitLayout(value));
+ } catch {
+ return false;
+ }
+ try {
+ storage.setItem(
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+ value === null ? "" : serializeLegacySplitLayout(value),
+ );
+ } catch {
+ return true;
+ }
+ return true;
+}
+
+export function createSplitLayoutStorage(): SyncStorage {
+ let pendingMigration: SplitLayout | null = null;
+ const checkpointMigration = (): boolean => {
+ if (pendingMigration === null) return true;
+ const initialized = listPanes(pendingMigration.root)
+ .map(
+ (pane) =>
+ pane.content.kind !== "new-thread" ||
+ initializeNewThreadDraft(pane.content.draftId, {}),
+ )
+ .every(Boolean);
+ return initialized;
+ };
+ return {
+ getItem(key, initialValue) {
+ if (pendingMigration !== null) return pendingMigration;
+ const session = browserStorage("sessionStorage");
+ for (const storage of [session, browserStorage("localStorage")]) {
+ const current = readStoredValue(storage, key);
+ if (current !== null)
+ return deserializeSplitLayout(current) ?? initialValue;
+ const legacy = readStoredValue(
+ storage,
+ LEGACY_SPLIT_LAYOUT_STORAGE_KEY,
+ );
+ if (legacy === null) continue;
+ const layout = deserializeLegacySplitLayout(legacy);
+ if (layout !== null) {
+ pendingMigration = layout;
+ if (checkpointMigration()) {
+ const persisted = writeLayout(storage, key, layout);
+ const persistedInSession =
+ storage !== session
+ ? writeLayout(session, key, layout)
+ : persisted;
+ if (persisted || persistedInSession) pendingMigration = null;
+ }
+ }
+ return layout ?? initialValue;
+ }
+ return initialValue;
+ },
+ setItem(key, value) {
+ if (!checkpointMigration()) return;
+ const persistedInSession = writeLayout(
+ browserStorage("sessionStorage"),
+ key,
+ value,
+ );
+ const persisted = writeLayout(browserStorage("localStorage"), key, value);
+ if (persistedInSession || persisted) pendingMigration = null;
+ },
+ removeItem(key) {
+ for (const storage of [
+ browserStorage("sessionStorage"),
+ browserStorage("localStorage"),
+ ]) {
+ try {
+ storage?.removeItem(key);
+ storage?.removeItem(LEGACY_SPLIT_LAYOUT_STORAGE_KEY);
+ } catch {
+ continue;
+ }
+ }
+ },
+ };
+}
diff --git a/apps/app/src/lib/split-layout/types.ts b/apps/app/src/lib/split-layout/types.ts
index 5282efb56e6..2a1a3321d80 100644
--- a/apps/app/src/lib/split-layout/types.ts
+++ b/apps/app/src/lib/split-layout/types.ts
@@ -6,6 +6,7 @@ export type PaneContent =
}
| {
kind: "new-thread";
+ draftId: string;
}
| {
kind: "plugin-panel";
diff --git a/apps/app/src/lib/thread-lifecycle-filter.ts b/apps/app/src/lib/thread-lifecycle-filter.ts
new file mode 100644
index 00000000000..2b060c702cf
--- /dev/null
+++ b/apps/app/src/lib/thread-lifecycle-filter.ts
@@ -0,0 +1,23 @@
+import type { ThreadLifecycle } from "@bb/domain";
+
+export const THREAD_LIFECYCLE_OPTIONS = [
+ { value: "active", label: "Active" },
+ { value: "drafts", label: "Drafts" },
+ { value: "archived", label: "Archived" },
+] as const satisfies ReadonlyArray<{ value: ThreadLifecycle; label: string }>;
+
+export function toggleThreadLifecycle(
+ selected: readonly ThreadLifecycle[],
+ value: ThreadLifecycle,
+): ThreadLifecycle[] {
+ const next = new Set(selected);
+ if (next.has(value)) {
+ if (next.size === 1) return [...selected];
+ next.delete(value);
+ } else {
+ next.add(value);
+ }
+ return THREAD_LIFECYCLE_OPTIONS.flatMap((option) =>
+ next.has(option.value) ? [option.value] : [],
+ );
+}
diff --git a/apps/app/src/lib/ws.test.ts b/apps/app/src/lib/ws.test.ts
index 39a47f7159b..6d644eb1066 100644
--- a/apps/app/src/lib/ws.test.ts
+++ b/apps/app/src/lib/ws.test.ts
@@ -207,6 +207,40 @@ describe("WebSocketManager thread-open signals", () => {
instance.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
}
+ it("routes draft opens to their own listeners and ignores malformed signals", () => {
+ const { manager } = createConnectedManager();
+ const opened = vi.fn();
+ const changed = vi.fn();
+ const unsubscribe = manager.onDraftOpen(opened);
+ manager.onChanged(changed);
+ dispatchRaw({
+ type: "draft-open",
+ draftId: "drf_valid_signal",
+ split: "down",
+ futureField: true,
+ });
+ expect(opened).toHaveBeenCalledWith({
+ type: "draft-open",
+ draftId: "drf_valid_signal",
+ split: "down",
+ });
+ dispatchRaw({ type: "draft-open", draftId: "invalid", split: "right" });
+ dispatchRaw({
+ type: "draft-open",
+ draftId: "drf_valid_signal",
+ split: "diagonal",
+ });
+ expect(opened).toHaveBeenCalledTimes(1);
+ expect(changed).not.toHaveBeenCalled();
+ unsubscribe();
+ dispatchRaw({
+ type: "draft-open",
+ draftId: "drf_valid_signal",
+ split: "right",
+ });
+ expect(opened).toHaveBeenCalledTimes(1);
+ });
+
it("notifies layout listeners and buffers an included file once", () => {
const { manager } = createConnectedManager();
const threadOpen = vi.fn();
diff --git a/apps/app/src/lib/ws.ts b/apps/app/src/lib/ws.ts
index b8d84210db3..fc3b986a83f 100644
--- a/apps/app/src/lib/ws.ts
+++ b/apps/app/src/lib/ws.ts
@@ -1,6 +1,7 @@
import ReconnectingWebSocket from "partysocket/ws";
import {
changedMessageLenientSchema,
+ draftOpenSignalLenientSchema,
pluginSignalLenientSchema,
pongMessageLenientSchema,
realtimeSubscriptionTargetKey,
@@ -9,6 +10,7 @@ import {
} from "@bb/server-contract";
import type {
ClientMessage,
+ DraftOpenSignal,
ChangedMessage,
PluginSignal,
RealtimeSubscriptionTarget,
@@ -23,6 +25,7 @@ import {
} from "./document-visibility";
type ChangeCallback = (message: ChangedMessage) => void;
+type DraftOpenCallback = (signal: DraftOpenSignal) => void;
type ThreadOpenCallback = (signal: ThreadOpenSignal) => void;
type ThreadPaneActionCallback = (signal: ThreadPaneActionSignal) => void;
type PluginSignalCallback = (signal: PluginSignal) => void;
@@ -73,6 +76,7 @@ export class WebSocketManager {
private socket: ReconnectingWebSocket | null = null;
private subscriptions = new Map();
private callbacks = new Set();
+ private draftOpenCallbacks = new Set();
private threadOpenCallbacks = new Set();
private threadPaneActionCallbacks = new Set();
private pluginSignalCallbacks = new Set();
@@ -279,6 +283,12 @@ export class WebSocketManager {
return;
}
+ const draftOpen = draftOpenSignalLenientSchema.safeParse(parsed);
+ if (draftOpen.success) {
+ for (const cb of this.draftOpenCallbacks) cb(draftOpen.data);
+ return;
+ }
+
const threadOpen = threadOpenSignalLenientSchema.safeParse(parsed);
if (threadOpen.success) {
if (threadOpen.data.file !== null) {
@@ -374,6 +384,13 @@ export class WebSocketManager {
};
}
+ onDraftOpen(callback: DraftOpenCallback): () => void {
+ this.draftOpenCallbacks.add(callback);
+ return () => {
+ this.draftOpenCallbacks.delete(callback);
+ };
+ }
+
onThreadOpen(callback: ThreadOpenCallback): () => void {
this.threadOpenCallbacks.add(callback);
return () => {
diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx
index caa388aa2a1..00bba581e7f 100644
--- a/apps/app/src/views/RootComposeView.tsx
+++ b/apps/app/src/views/RootComposeView.tsx
@@ -1,11 +1,16 @@
import { useInitialPromptDraft } from "@/components/promptbox/mentions/initial-prompt-draft";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { useLocation, useNavigate } from "react-router-dom";
-import { useQueryClient } from "@tanstack/react-query";
import {
- findCachedProviderInfo,
- useSystemProviders,
-} from "@/hooks/queries/system-queries";
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { useStore } from "jotai";
+import { useLocation, useNavigate } from "react-router-dom";
+import { useSystemProviders } from "@/hooks/queries/system-queries";
import {
findLocalPathProjectSourceForHost,
type EnvironmentStatus,
@@ -16,6 +21,8 @@ import {
type ThreadListEntry,
} from "@bb/domain";
import type {
+ DraftContent,
+ DraftOptions,
SidebarBootstrapResponse,
TerminalSession,
} from "@bb/server-contract";
@@ -65,7 +72,19 @@ import { PluginIcon } from "@/components/plugin/PluginIcon";
import type { FileOpenerOverride } from "@/lib/plugin-slot-resolvers";
import { usePluginNewThreadPanelActions } from "@/components/plugin/PluginPanelActions";
import { usePluginSlots } from "@/lib/plugin-slots";
-import { useCreateThread } from "@/hooks/mutations/thread-runtime-mutations";
+import { useDraftResource } from "@/hooks/useDraftResource";
+import { createNewThreadDraft } from "@/lib/drafts/resource-runtime";
+import { getDraftRoutePath } from "@/lib/draft-route";
+import { splitLayoutAtom } from "@/lib/split-layout/atoms";
+import type { PaneContent } from "@/lib/split-layout";
+import {
+ ownsRootComposeLocation,
+ replaceRootDraftOrigin,
+ rootComposeRouteDraftId,
+ rootDraftComposerSeed,
+ rootDraftSubmissionContent,
+ type RootDraftOrigin,
+} from "./root-compose-draft";
import {
useCloseTerminal,
useCloseEnvironmentTerminal,
@@ -85,7 +104,6 @@ import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/pr
import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject";
import type { PromptDraftAttachment } from "@bb/client-core";
import {
- buildForkThreadRequest,
FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY,
type ForkThreadCreateSeed,
} from "@bb/client-core";
@@ -136,6 +154,7 @@ import {
toFilePreviewLineRange,
} from "@/lib/live-file-navigation";
import {
+ rootComposeProjectIdAtom,
useRootComposeProjectId,
useSetRootComposeProjectId,
} from "@/lib/root-compose-selection";
@@ -499,132 +518,513 @@ export function LegacyProjectComposeRedirect({
return ;
}
-export function RootComposeView() {
- const [rootComposeProjectId, setRootComposeProjectId] =
- useRootComposeProjectId();
- const location = useLocation();
- const navigate = useNavigate();
- const queryClient = useQueryClient();
- const createThread = useCreateThread();
- const [rootComposeSectionId, setRootComposeSectionId] = useState<
- string | null
- >(() => readSectionIdFromLocationState(location.state));
+export function RootComposeView({
+ draftId: paneDraftId,
+}: { draftId?: string } = {}) {
+ const [defaultProjectId] = useRootComposeProjectId();
+ const store = useStore();
const [lastCreatedThreadId, setLastCreatedThreadId] = useState(
null,
);
- const [startedComposing, setStartedComposing] = useState(() =>
- shouldStartComposingFromLocationState(location.state),
+ const location = useLocation();
+ const navigate = useNavigate();
+ const paneContext = useOptionalPaneContext();
+ const bootstrapDraftId = useRef(null);
+ const draftId = paneDraftId ?? rootComposeRouteDraftId(location);
+
+ useEffect(() => {
+ if (draftId !== null) {
+ bootstrapDraftId.current = null;
+ return;
+ }
+ if (location.pathname !== "/" || paneContext?.isFocused === false) return;
+ const id =
+ bootstrapDraftId.current ??
+ createNewThreadDraft({
+ projectId: store.get(rootComposeProjectIdAtom),
+ sectionId: readSectionIdFromLocationState(location.state),
+ });
+ bootstrapDraftId.current = id;
+ const search = new URLSearchParams(location.search);
+ search.set("draft", id);
+ navigate(`/?${search.toString()}`, {
+ replace: true,
+ state: location.state,
+ });
+ }, [
+ defaultProjectId,
+ store,
+ draftId,
+ location.pathname,
+ location.search,
+ location.state,
+ navigate,
+ paneContext?.isFocused,
+ ]);
+
+ return draftId === null ? (
+
+ ) : (
+
);
+}
+
+function RootComposeDraft({
+ draftId,
+ lastCreatedThreadId,
+ onCreatedThread,
+}: {
+ draftId: string;
+ lastCreatedThreadId: string | null;
+ onCreatedThread: (id: string) => void;
+}) {
+ const resource = useDraftResource(draftId);
+ const {
+ edit: editDraft,
+ submit: submitDraft,
+ retry: retryDraft,
+ reloadRemote: reloadDraft,
+ } = resource;
+ const setDefaultProjectId = useSetRootComposeProjectId();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const store = useStore();
+ const paneContext = useOptionalPaneContext();
+ const locationRef = useRef(location);
+ useLayoutEffect(() => {
+ locationRef.current = location;
+ }, [location]);
+ const [startedComposing, setStartedComposing] = useState(
+ () =>
+ ownsRootComposeLocation(
+ draftId,
+ paneContext?.isFocused ?? true,
+ location,
+ ) && shouldStartComposingFromLocationState(location.state),
+ );
+ useEffect(() => {
+ if (
+ resource.promptDraft.text.length > 0 ||
+ resource.promptDraft.attachments.length > 0
+ ) {
+ setStartedComposing(true);
+ }
+ }, [resource.promptDraft.text, resource.promptDraft.attachments.length]);
+ const [sourceThreadTitle, setSourceThreadTitle] = useState("Source thread");
+ const [actionError, setActionError] = useState(null);
+ const [actionPending, setActionPending] = useState(false);
const [navigateToThreadAfterCreate] =
useNavigateToThreadAfterCreatePreference();
- const [forkSeed, setForkSeed] = useState(() =>
- readForkThreadCreateSeedFromLocationState(location.state),
+ const submissionInFlight = useRef(false);
+ const pendingSubmission = useRef<{
+ origin: RootDraftOrigin;
+ navigateAfter: boolean;
+ } | null>(null);
+ const reboundDeletedDraft = useRef(false);
+ const content = resource.content;
+ const lastUsableContent = useRef(null);
+ if (content !== null && resource.status !== "deleted") {
+ lastUsableContent.current = content;
+ }
+ const isForkDraft = content?.options.originKind === "fork";
+ const forkSeed = isForkDraft ? { sourceThreadTitle } : null;
+ const origin = useCallback(
+ (): RootDraftOrigin => ({
+ draftId,
+ paneId: paneContext?.paneId ?? null,
+ hadLayout: store.get(splitLayoutAtom) !== null,
+ }),
+ [draftId, paneContext?.paneId, store],
+ );
+ const replaceOrigin = useCallback(
+ (target: RootDraftOrigin, destination: PaneContent) => {
+ const current = store.get(splitLayoutAtom);
+ const result = replaceRootDraftOrigin({
+ layout: current,
+ origin: target,
+ destination,
+ currentRouteDraftId: rootComposeRouteDraftId(locationRef.current),
+ });
+ if (result.layout !== null && result.layout !== current) {
+ store.set(splitLayoutAtom, result.layout);
+ }
+ if (!result.navigate) return;
+ if (destination.kind === "thread") {
+ navigate(getThreadRoutePath(destination));
+ } else if (destination.kind === "new-thread") {
+ navigate(getDraftRoutePath(destination.draftId), { replace: true });
+ }
+ },
+ [navigate, store],
);
-
const handleProjectChange = useCallback(
(projectId: string) => {
- setForkSeed(null);
- setRootComposeProjectId(projectId);
+ editDraft((current) => ({
+ ...current,
+ projectId,
+ options: {
+ ...current.options,
+ sourceThreadId: null,
+ sourceSeqEnd: null,
+ originKind: null,
+ },
+ }));
+ setDefaultProjectId(projectId);
+ },
+ [editDraft, setDefaultProjectId],
+ );
+ const setForkSeed = useCallback(
+ (seed: ForkThreadCreateSeed | null) => {
+ if (seed === null) {
+ editDraft((current) => ({
+ ...current,
+ options: {
+ ...current.options,
+ sourceThreadId: null,
+ sourceSeqEnd: null,
+ originKind: null,
+ },
+ }));
+ return;
+ }
+ setSourceThreadTitle(seed.sourceThreadTitle);
+ setDefaultProjectId(seed.projectId);
+ editDraft((current) => ({
+ ...current,
+ projectId: seed.projectId,
+ options: {
+ ...current.options,
+ providerId: seed.providerId,
+ model: seed.model,
+ reasoningLevel: seed.reasoningLevel,
+ serviceTier: seed.serviceTier ?? null,
+ permissionMode: seed.permissionMode,
+ environment: { type: "reuse", environmentId: seed.environmentId },
+ sourceThreadId: seed.sourceThreadId,
+ sourceSeqEnd: seed.sourceSeqEnd ?? null,
+ originKind: "fork",
+ },
+ }));
+ },
+ [editDraft, setDefaultProjectId],
+ );
+ const setSectionId = useCallback(
+ (sectionId: string | null) => {
+ editDraft((current) => ({ ...current, sectionId }));
+ },
+ [editDraft],
+ );
+ const setReuseEnvironment = useCallback(
+ (environmentId: string) => {
+ editDraft((current) => ({
+ ...current,
+ options: {
+ ...current.options,
+ environment: { type: "reuse", environmentId },
+ },
+ }));
+ },
+ [editDraft],
+ );
+ const awaitingLocationSeed =
+ ownsRootComposeLocation(
+ draftId,
+ paneContext?.isFocused ?? true,
+ location,
+ ) && hasSingleUseRootComposeTargetState(location.state);
+ const handleOptionsChange = useCallback(
+ (
+ options: Pick<
+ DraftOptions,
+ | "providerId"
+ | "model"
+ | "reasoningLevel"
+ | "serviceTier"
+ | "permissionMode"
+ | "environment"
+ >,
+ ) => {
+ if (
+ awaitingLocationSeed ||
+ resource.status === "loading" ||
+ resource.status === "deleted" ||
+ resource.content === null
+ )
+ return;
+ editDraft((current) => ({
+ ...current,
+ options: { ...current.options, ...options },
+ }));
+ },
+ [awaitingLocationSeed, resource.content, editDraft, resource.status],
+ );
+ const completeSubmission = useCallback(
+ (
+ result: Awaited>,
+ submission: { origin: RootDraftOrigin; navigateAfter: boolean },
+ ) => {
+ pendingSubmission.current = null;
+ if (result.draft === null) reboundDeletedDraft.current = true;
+ onCreatedThread(result.thread.id);
+ if (submission.navigateAfter) {
+ replaceOrigin(submission.origin, {
+ kind: "thread",
+ projectId: result.thread.projectId,
+ threadId: result.thread.id,
+ });
+ } else if (result.draft === null) {
+ const previous = lastUsableContent.current;
+ const nextDraftId =
+ result.recoveryDraftId ??
+ createNewThreadDraft({
+ projectId: previous?.projectId ?? null,
+ options: previous
+ ? {
+ providerId: previous.options.providerId,
+ model: previous.options.model,
+ reasoningLevel: previous.options.reasoningLevel,
+ serviceTier: previous.options.serviceTier,
+ permissionMode: previous.options.permissionMode,
+ environment: previous.options.environment,
+ }
+ : {},
+ });
+ replaceOrigin(submission.origin, {
+ kind: "new-thread",
+ draftId: nextDraftId,
+ });
+ }
+ },
+ [onCreatedThread, replaceOrigin],
+ );
+ const submitResource = useCallback(
+ async (submission: { origin: RootDraftOrigin; navigateAfter: boolean }) => {
+ submissionInFlight.current = true;
+ pendingSubmission.current = submission;
+ try {
+ completeSubmission(await submitDraft(), submission);
+ } finally {
+ submissionInFlight.current = false;
+ }
},
- [setRootComposeProjectId],
+ [completeSubmission, submitDraft],
);
+ const retryResource = useCallback(async () => {
+ if (pendingSubmission.current !== null) {
+ await submitResource(pendingSubmission.current);
+ return;
+ }
+ const submission = {
+ origin: origin(),
+ navigateAfter: shouldNavigateAfterThreadCreate({
+ isForkDraft,
+ navigateToThreadAfterCreate,
+ }),
+ };
+ submissionInFlight.current = true;
+ try {
+ const result = await retryDraft();
+ if (result !== null) completeSubmission(result, submission);
+ } finally {
+ submissionInFlight.current = false;
+ }
+ }, [
+ completeSubmission,
+ isForkDraft,
+ navigateToThreadAfterCreate,
+ origin,
+ retryDraft,
+ submitResource,
+ ]);
+ const useSavedVersion = useCallback(async () => {
+ await reloadDraft();
+ pendingSubmission.current = null;
+ }, [reloadDraft]);
const handleSubmit = useCallback(
async (request: NewThreadComposerSubmission) => {
- const shouldNavigateToCreatedThread = shouldNavigateAfterThreadCreate({
- isForkDraft: forkSeed !== null,
- navigateToThreadAfterCreate,
+ editDraft((current) => rootDraftSubmissionContent(current, request));
+ await submitResource({
+ origin: origin(),
+ navigateAfter: shouldNavigateAfterThreadCreate({
+ isForkDraft,
+ navigateToThreadAfterCreate,
+ }),
});
- const { sendAt, ...requestFields } = request;
- const createRequest =
- forkSeed === null
- ? {
- ...requestFields,
- ...(rootComposeSectionId
- ? { sectionId: rootComposeSectionId }
- : {}),
- }
- : buildForkThreadRequest({
- ...forkSeed,
- input: request.input,
- model: request.model,
- permissionMode: request.permissionMode,
- pluginSubmission: request.pluginSubmission,
- providerSupportsFork:
- findCachedProviderInfo(queryClient, forkSeed.providerId)
- ?.capabilities.supportsFork ?? false,
- reasoningLevel: request.reasoningLevel,
- serviceTier: request.serviceTier,
- });
- if (createRequest === null) return;
- const thread = await createThread.mutateAsync(
- sendAt === undefined
- ? createRequest
- : {
- ...createRequest,
- ...(sendAt === undefined ? {} : { sendAt }),
- },
- );
- setLastCreatedThreadId(thread.id);
- setForkSeed(null);
- setRootComposeSectionId(null);
- if (shouldNavigateToCreatedThread) {
- navigate(
- getThreadRoutePath({
- projectId: thread.projectId,
- threadId: thread.id,
- }),
- );
- }
},
[
- createThread,
- forkSeed,
- queryClient,
- navigate,
+ isForkDraft,
navigateToThreadAfterCreate,
- rootComposeSectionId,
+ origin,
+ editDraft,
+ submitResource,
],
);
+ useEffect(() => {
+ if (
+ resource.status !== "deleted" ||
+ resource.recoveryCopies.length > 0 ||
+ actionPending ||
+ submissionInFlight.current ||
+ pendingSubmission.current !== null ||
+ reboundDeletedDraft.current
+ )
+ return;
+ reboundDeletedDraft.current = true;
+ replaceOrigin(origin(), {
+ kind: "new-thread",
+ draftId: createNewThreadDraft({
+ projectId: lastUsableContent.current?.projectId ?? null,
+ }),
+ });
+ }, [
+ actionPending,
+ origin,
+ replaceOrigin,
+ resource.recoveryCopies.length,
+ resource.status,
+ ]);
+ const runAction = useCallback(async (action: () => void | Promise) => {
+ setActionError(null);
+ setActionPending(true);
+ try {
+ await action();
+ } catch (error) {
+ setActionError(
+ error instanceof Error ? error.message : "Could not update this draft.",
+ );
+ } finally {
+ setActionPending(false);
+ }
+ }, []);
+ const resourceNotice = (
+
+
+ {resource.status === "loading"
+ ? "Loading draft…"
+ : resource.status === "saving"
+ ? "Saving draft…"
+ : resource.status === "conflict"
+ ? "This draft changed elsewhere. Your edits are kept here."
+ : resource.status === "deleted"
+ ? "This draft was sent or deleted. Recover your edits as a copy."
+ : resource.status === "error" || resource.persistenceError
+ ? "Draft not saved."
+ : "Draft saved"}
+
+ {actionError || resource.error || resource.persistenceError ? (
+
+ {actionError ??
+ resource.error?.message ??
+ resource.persistenceError?.message}
+
+ ) : null}
+ {resource.status === "error" ||
+ resource.persistenceError ||
+ pendingSubmission.current !== null ? (
+
+ ) : null}
+ {resource.status === "conflict" ? (
+
+ ) : null}
+ {resource.recoveryCopies.map((_, index) => (
+
+ ))}
+
+ );
const composerSeed = useMemo(
() =>
- forkSeed === null
- ? undefined
- : {
- providerId: forkSeed.providerId,
- model: forkSeed.model,
- reasoningLevel: forkSeed.reasoningLevel,
- serviceTier: forkSeed.serviceTier,
- permissionMode: forkSeed.permissionMode,
- environment: {
- type: "reuse" as const,
- environmentId: forkSeed.environmentId,
- },
- },
- [forkSeed],
+ content === null ? undefined : rootDraftComposerSeed(content.options),
+ [content],
);
-
+ if (content === null) {
+ return (
+
+ {resourceNotice}
+
+ );
+ }
return (
{(composer) => (
0 ||
+ resource.promptDraft.attachments.length > 0 ||
+ resource.status === "error" ||
+ resource.status === "conflict" ||
+ resource.status === "deleted"
+ }
+ resourceNotice={resourceNotice}
+ canApplyLocationSeeds={
+ resource.status !== "loading" &&
+ resource.status !== "conflict" &&
+ resource.status !== "deleted"
+ }
/>
)}
@@ -633,24 +1033,30 @@ export function RootComposeView() {
interface RootComposeSurfaceProps {
composer: NewThreadComposerState;
- forkSeed: ForkThreadCreateSeed | null;
+ draftId: string;
+ forkSeed: Pick | null;
lastCreatedThreadId: string | null;
- rootComposeProjectId: string;
+ resourceNotice: ReactNode;
+ canApplyLocationSeeds: boolean;
setForkSeed: (seed: ForkThreadCreateSeed | null) => void;
setRootComposeProjectId: (projectId: string) => void;
setRootComposeSectionId: (sectionId: string | null) => void;
+ setReuseEnvironment: (environmentId: string) => void;
setStartedComposing: (started: boolean) => void;
startedComposing: boolean;
}
function RootComposeSurface({
composer,
+ draftId,
forkSeed,
lastCreatedThreadId,
- rootComposeProjectId,
+ resourceNotice,
+ canApplyLocationSeeds,
setForkSeed,
setRootComposeProjectId,
setRootComposeSectionId,
+ setReuseEnvironment,
setStartedComposing,
startedComposing,
}: RootComposeSurfaceProps) {
@@ -702,10 +1108,6 @@ function RootComposeSurface({
[promptDraft.storageKey, sharedPluginComposerHost],
);
- useEffect(() => {
- if (projectId === rootComposeProjectId) return;
- setRootComposeProjectId(projectId);
- }, [projectId, rootComposeProjectId, setRootComposeProjectId]);
useEffect(
() =>
subscribeComposerFocusRequests(promptDraft.storageKey, () => {
@@ -730,49 +1132,49 @@ function RootComposeSurface({
const setPromptDraft = promptDraft.setDraft;
const restorePromptDraftIfEmpty = promptDraft.restoreIfEmpty;
+ const consumedLocationKey = useRef(null);
+ const ownsLocation =
+ canApplyLocationSeeds &&
+ ownsRootComposeLocation(draftId, isFocusedPane, location);
useEffect(() => {
- const initialPrompt = readInitialPromptFromSearch(location.search);
- if (initialPrompt === null || searchInitialDraft === undefined) return;
- setStartedComposing(true);
- setPromptDraft(searchInitialDraft);
- navigate(
- getRootComposeRoutePath() + stripInitialPromptFromSearch(location.search),
- { replace: true, state: location.state },
- );
- }, [
- location.search,
- location.state,
- navigate,
- setPromptDraft,
- setStartedComposing,
- searchInitialDraft,
- ]);
- useEffect(() => {
- if (stateInitialPrompt !== null && stateInitialDraft === undefined) return;
- const sectionTarget = readRootComposeSectionTargetFromLocationState(
+ if (!ownsLocation || consumedLocationKey.current === location.key) return;
+ const queryPrompt = readInitialPromptFromSearch(location.search);
+ const initialPrompt = readInitialPromptFromLocationState(location.state);
+ const nextForkSeed = readForkThreadCreateSeedFromLocationState(
location.state,
);
const reuseEnvironmentId = readReuseEnvironmentIdFromLocationState(
location.state,
);
- const nextForkSeed = readForkThreadCreateSeedFromLocationState(
- location.state,
- );
- if (!hasSingleUseRootComposeTargetState(location.state)) return;
- if (shouldStartComposingFromLocationState(location.state)) {
- setStartedComposing(true);
+ const hasSectionTarget =
+ typeof location.state === "object" &&
+ location.state !== null &&
+ "sectionId" in location.state;
+ const shouldFocus = shouldStartComposingFromLocationState(location.state);
+ if (
+ queryPrompt === null &&
+ initialPrompt === null &&
+ !hasSingleUseRootComposeTargetState(location.state)
+ )
+ return;
+ if (
+ (queryPrompt !== null && searchInitialDraft === undefined) ||
+ (initialPrompt !== null && stateInitialDraft === undefined)
+ )
+ return;
+ consumedLocationKey.current = location.key;
+ if (queryPrompt !== null && searchInitialDraft !== undefined) {
+ setPromptDraft(searchInitialDraft);
}
- if (sectionTarget?.kind === "set") {
- setRootComposeSectionId(sectionTarget.sectionId);
- } else if (sectionTarget?.kind === "clear") {
- setRootComposeSectionId(null);
+ if (hasSectionTarget) {
+ setRootComposeSectionId(readSectionIdFromLocationState(location.state));
}
if (reuseEnvironmentId !== null) {
+ setReuseEnvironment(reuseEnvironmentId);
seedEnvironmentSelectionValue(encodeReuseValue(reuseEnvironmentId));
}
if (nextForkSeed !== null) {
setForkSeed(nextForkSeed);
- setRootComposeProjectId(nextForkSeed.projectId);
setProviderModelReasoning(nextForkSeed);
setPermissionMode(nextForkSeed.permissionMode);
setServiceTier(nextForkSeed.serviceTier);
@@ -780,56 +1182,52 @@ function RootComposeSurface({
encodeReuseValue(nextForkSeed.environmentId),
);
}
- navigate(getRootComposeRoutePath() + location.search, {
- replace: true,
- state: null,
- });
+ if (initialPrompt !== null && stateInitialDraft !== undefined) {
+ const nextDraft = stateInitialDraft;
+ if (shouldReplaceInitialPromptFromLocationState(location.state)) {
+ setPromptDraft(nextDraft);
+ } else {
+ restorePromptDraftIfEmpty(nextDraft);
+ }
+ }
+ if (
+ shouldFocus ||
+ queryPrompt !== null ||
+ initialPrompt !== null ||
+ nextForkSeed !== null
+ ) {
+ setStartedComposing(true);
+ if (!isPointerCoarse) {
+ window.requestAnimationFrame(focusPromptBox);
+ }
+ }
+ navigate(
+ getRootComposeRoutePath() + stripInitialPromptFromSearch(location.search),
+ { replace: true, state: null },
+ );
}, [
+ isPointerCoarse,
+ location.key,
location.search,
location.state,
navigate,
+ ownsLocation,
+ focusPromptBox,
+ restorePromptDraftIfEmpty,
seedEnvironmentSelectionValue,
setForkSeed,
setPermissionMode,
setProviderModelReasoning,
+ setReuseEnvironment,
setRootComposeProjectId,
setRootComposeSectionId,
setServiceTier,
setStartedComposing,
stateInitialPrompt,
stateInitialDraft,
- ]);
- useEffect(() => {
- const initialPrompt = readInitialPromptFromLocationState(location.state);
- if (initialPrompt === null || stateInitialDraft === undefined) return;
- const nextDraft = stateInitialDraft;
- if (shouldReplaceInitialPromptFromLocationState(location.state)) {
- setPromptDraft(nextDraft);
- } else {
- restorePromptDraftIfEmpty(nextDraft);
- }
- navigate(getRootComposeRoutePath() + location.search, {
- replace: true,
- state: { focusPrompt: true },
- });
- }, [
- location.search,
- location.state,
- navigate,
- restorePromptDraftIfEmpty,
+ searchInitialDraft,
setPromptDraft,
- stateInitialDraft,
]);
- const shouldFocusPrompt =
- typeof location.state === "object" &&
- location.state !== null &&
- "focusPrompt" in location.state &&
- location.state.focusPrompt === true;
- useEffect(() => {
- if (!shouldFocusPrompt || isPointerCoarse) return;
- const handle = window.requestAnimationFrame(focusPromptBox);
- return () => window.cancelAnimationFrame(handle);
- }, [focusPromptBox, isPointerCoarse, location.key, shouldFocusPrompt]);
const mobileRecentThreads = useMemo(
() => buildMobileRecentThreads({ sidebarNavigation }),
@@ -1808,12 +2206,13 @@ function RootComposeSurface({
[setPromptTextAndMentions, setStartedComposing],
);
useEffect(() => {
- if (!startedComposing) return;
+ if (!startedComposing || !isFocusedPane) return;
if (isProviderCliVersionBlocked) return;
if (isPointerCoarse) return;
const handle = window.requestAnimationFrame(focusPromptBox);
return () => window.cancelAnimationFrame(handle);
}, [
+ isFocusedPane,
isProviderCliVersionBlocked,
isPointerCoarse,
focusPromptBox,
@@ -1919,16 +2318,6 @@ function RootComposeSurface({
selectedProviderId,
]);
- if (!projects && sidebarNavigationError) {
- return (
-
-
- Failed to load projects.
-
-
- );
- }
-
const machineSetupDialog = (
+ {resourceNotice}
+ {!projects && sidebarNavigationError ? (
+
+ Could not load projects. Your draft is kept here.
+
+ ) : null}
+ {promptBanner}
+ >
+ ),
header: promptHeader,
blockedReason: isProviderCliVersionBlocked
? `Update ${selectedProviderCliStatus?.displayName ?? selectedProviderId} before starting a thread.`
@@ -1956,7 +2355,8 @@ function RootComposeSurface({
textEffects: promptTextEffects,
allowNoProject: true,
createProject: {
- onCreate: quickCreateProject.openCreateDialog,
+ onCreate: () =>
+ quickCreateProject.openCreateDialogForSelection(composer.selectProject),
disabled:
!quickCreateProject.isAvailable || quickCreateProject.isCreating,
isCreating: quickCreateProject.isCreating,
diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx
index 881c9348c94..a74ba4e9f2e 100644
--- a/apps/app/src/views/SettingsView.tsx
+++ b/apps/app/src/views/SettingsView.tsx
@@ -1,12 +1,8 @@
+import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft";
import { MachineEnvironmentSettings } from "@/components/settings/MachineEnvironmentSettings";
import { MachineAccessSettings } from "@/components/settings/MachineAccessSettings";
import { useMemo, useRef, useState, type ReactNode } from "react";
-import {
- Navigate,
- useNavigate,
- useLocation,
- matchPath,
-} from "react-router-dom";
+import { Navigate, useLocation, matchPath } from "react-router-dom";
import "@bb/shared-ui/icon-extended";
import {
builtInThemes,
@@ -87,10 +83,7 @@ import {
import { useOpenLinksInAppBrowserPreference } from "@/lib/in-app-browser-link-preference";
import { useRewriteLocalhostLinksPreference } from "@/lib/localhost-link-rewrite-preference";
import { useRichTextEditingPreference } from "@/lib/rich-text-editing-preference";
-import {
- SETTINGS_ROUTE_PATH,
- getRootComposeRoutePath,
-} from "@/lib/route-paths";
+import { SETTINGS_ROUTE_PATH } from "@/lib/route-paths";
import { useNavigateToThreadAfterCreatePreference } from "@/lib/root-compose-create-preference";
import { cn } from "@bb/shared-ui/lib/utils";
import {
@@ -1089,7 +1082,7 @@ export function ExperimentsSettingsSection({
}
export function SettingsView() {
- const navigate = useNavigate();
+ const openNewDraft = useOpenNewThreadDraft();
const themePreference = useThemePreference();
const systemConfigQuery = useSystemConfig();
const { hasDaemon } = useHostDaemon();
@@ -1180,12 +1173,15 @@ export function SettingsView() {
onAppearanceThemePrefetch={appThemePreview.prefetchThemes}
onAppearanceThemePreview={appThemePreview.previewTheme}
onCreatePalette={() =>
- navigate(getRootComposeRoutePath(), {
- state: {
- focusPrompt: true,
- initialPrompt: CREATE_CUSTOM_PALETTE_PROMPT,
+ openNewDraft(
+ {},
+ {
+ state: {
+ focusPrompt: true,
+ initialPrompt: CREATE_CUSTOM_PALETTE_PROMPT,
+ },
},
- })
+ )
}
onFaviconColorChange={(faviconColor) =>
updateAppearanceMutation.mutate({
diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx
index 1643787d3fb..de42ce4aa38 100644
--- a/apps/app/src/views/SkillsView.test.tsx
+++ b/apps/app/src/views/SkillsView.test.tsx
@@ -22,6 +22,8 @@ import type { SkillSummary } from "@bb/server-contract";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { makeProviderInfo } from "@bb/test-helpers/domain-fixtures";
+import { RouteNavigationProvider } from "@/components/ui/app-route-anchor";
+import { parseDraftRouteId } from "@/lib/draft-route";
import { sdk } from "@/lib/sdk";
import {
buildRegistrySkillReferencePrompt,
@@ -39,9 +41,16 @@ import {
import { SkillsLibrary } from "../components/tools/SkillsLibrary";
import { focusWithKeyboard } from "@/test/keyboard-focus";
+vi.mock("@/lib/drafts/resource-runtime", async (importOriginal) => ({
+ ...(await importOriginal()),
+ createNewThreadDraft: vi.fn(() => `drf_${crypto.randomUUID()}`),
+}));
+
afterEach(() => {
focusManager.setFocused(undefined);
cleanup();
+ window.localStorage.clear();
+ window.sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -87,7 +96,10 @@ function requestPath(input: RequestInfo | URL): string {
function LocationStateProbe() {
const location = useLocation();
return (
-