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 a827c036705..248e90f7b79 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 f8951e1f06d..0d7af028a71 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -1,3 +1,4 @@ +import { LegacyDraftImport } from "@/components/drafts/LegacyDraftImport"; import { type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; @@ -95,12 +96,17 @@ 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 SIDEBAR_WIDTH_KEY = "bb.sidebar.width"; @@ -408,6 +414,7 @@ export function AppLayout({ children }: AppLayoutProps) { toolsRoutePath, } = useAppSettingsRouteMemory(); const setRootComposeProjectId = useSetRootComposeProjectId(); + const [rootComposeProjectId] = useRootComposeProjectId(); useEffect( () => wsManager.onThreadOpen((signal) => { @@ -432,12 +439,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; }); @@ -712,6 +740,7 @@ 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 89786e90890..fdcc10c17b4 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx @@ -58,6 +58,11 @@ import { 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 +202,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", }); @@ -313,6 +318,7 @@ beforeEach(() => { resetPluginFrontendBootStateForTest(); markPluginFrontendsSettled(); window.localStorage.clear(); + window.sessionStorage.clear(); resetAllCrashedPluginSlotsForTest(); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -326,6 +332,7 @@ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); window.localStorage.clear(); + window.sessionStorage.clear(); }); describe("PluginNavSidebarItems", () => { @@ -659,7 +666,7 @@ describe("PluginNavSidebarItems", () => { root: { type: "split", dir: "row", - sizes: [1, 1, 1], + sizes: [1 / 3, 1 / 3, 1 / 3], children: [ { type: "pane", diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx index 16db155ed66..f02a7268d6f 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx @@ -1,3 +1,4 @@ +import { createNewThreadDraft } from "@/lib/drafts/resource-runtime"; import { useCallback, useEffect, @@ -270,7 +271,10 @@ function PluginNavSidebarItemList({ 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/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index b5bc5a73d2e..ed58a547347 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 { buildThreadHandoffLocationState } from "@bb/client-core"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { makeProjectWithThreadsResponse } from "@/test/fixtures/projects"; @@ -66,9 +73,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[], @@ -186,11 +195,13 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ mocks.sidebarNavigationSettled ? { data: { - 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", @@ -393,6 +404,7 @@ vi.mock("@/hooks/useQuickCreateProject", () => ({ isAvailable: false, isCreating: false, openCreateDialog: vi.fn(), + openCreateDialogForSelection: mocks.createProjectForSelection, platform: null, projectPathDialog: { isOpen: false, @@ -644,6 +656,7 @@ describe("PluginNewThreadComposer seeding", () => { beforeEach(() => { resetFixedPanelTabsStateForTest(); mocks.closeTerminal.mockClear(); + mocks.createProjectForSelection.mockClear(); mocks.promptBoxProps.length = 0; mocks.promptHistoryQueryOptions.length = 0; mocks.copyAttachments.mockReset(); @@ -652,6 +665,7 @@ describe("PluginNewThreadComposer seeding", () => { mocks.sidebarNavigationSettled = true; mocks.sidebarNavigationReplayed = false; mocks.extraProjects = []; + mocks.noProjects = false; mocks.plugins = []; mocks.serverAccessReady = true; mocks.machineProviders = []; @@ -1447,6 +1461,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("keeps an unrelated draft attachment out of a RootComposeView handoff", async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -1490,10 +1620,10 @@ describe("PluginNewThreadComposer seeding", () => { ); expect(mocks.promptBoxProps[0]?.modeConfig.environment.value).toBe( - "provider:personal-workspace", + "provider:project-checkout", ); - expect(mocks.promptBoxProps[0]?.value).toBe("unrelated draft"); - expect(mocks.promptBoxProps[0]?.attachments.items).toHaveLength(1); + expect(mocks.promptBoxProps[0]?.value).toBe(""); + expect(mocks.promptBoxProps[0]?.attachments.items).toEqual([]); await waitFor(() => { expect(latestPromptBoxProps().value).toBe( "Continue from @thread:thr_source", @@ -1555,7 +1685,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) => @@ -1781,6 +1917,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 06167771184..d82cf8f6afa 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} + + + {JSON.stringify(location.state)} + + + ); } 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 fbde84a0ace..82280cca7e3 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( @@ -135,13 +134,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/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 40de3676527..33a01f14f1e 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -30,6 +30,7 @@ import type { } from "@get-bb/plugin-sdk"; import type { CreateExecutionInputSources, + DraftOptions, SidebarBootstrapResponse, SystemEnvironmentProvider, SystemExecutionOptionsModelLoadError, @@ -77,8 +78,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"; @@ -115,7 +127,9 @@ export interface NewThreadComposerSeed { reasoningLevel?: ReasoningLevel; serviceTier?: ServiceTier; permissionMode?: PermissionMode; - environment?: NewThreadRequest["environment"]; + environment?: + | NewThreadRequest["environment"] + | NonNullable; initialPrompt?: string; } @@ -176,6 +190,7 @@ export interface NewThreadComposerState { }) => void; setPermissionMode: (value: PermissionMode) => void; setServiceTier: (value: ServiceTier | undefined) => void; + selectProject: (projectId: string | null) => Promise; renderPromptBox: (options: NewThreadComposerPromptOptions) => ReactNode; } @@ -197,6 +212,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; @@ -385,10 +413,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, @@ -396,7 +444,8 @@ export function NewThreadComposer({ onSubmit, focusRequest, children, -}: NewThreadComposerProps) { +}: NewThreadComposerProps & { draftController: PromptDraftController }) { + const isResourceDraft = onOptionsChange !== undefined; const navigate = useNavigate(); const [localPromptBoxFocusRequest, setLocalPromptBoxFocusRequest] = useState< number | null @@ -422,9 +471,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) { @@ -444,8 +500,7 @@ export function NewThreadComposer({ const hostsQuery = useHosts(); const availableHosts = useMemo( - () => - selectHosts(hostsQuery.data, "persistent"), + () => selectHosts(hostsQuery.data, "persistent"), [hostsQuery.data], ); const systemConfigQuery = useSystemConfig(); @@ -565,10 +620,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 = picked ?? 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 }; @@ -583,6 +650,7 @@ export function NewThreadComposer({ }, [ seedOverridden, + isResourceDraft, environmentSeed, environmentProviders, isProjectless, @@ -597,16 +665,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" @@ -619,6 +690,7 @@ export function NewThreadComposer({ : {}; }, [ + isResourceDraft, environmentProviders, isProjectless, knownHostIds, @@ -655,21 +727,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, @@ -707,7 +810,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(() => { @@ -755,7 +857,7 @@ export function NewThreadComposer({ : { selectionValue: value, machine: providerMachine }, ); if ( - selectionScope === "new-thread" && + (selectionScope === "new-thread" || isResourceDraft) && parseEnvironmentValue(value)?.type === "provider" ) { setStoredMachineId( @@ -763,12 +865,15 @@ export function NewThreadComposer({ ); } setCreationEnvironmentSelectionValue(value); + if (isResourceDraft) setPreferredEnvironment(value); }, [ environmentSelectionValue, pickedProviderMachine, setCreationEnvironmentSelectionValue, selectionScope, + isResourceDraft, + setPreferredEnvironment, setStoredMachineId, snapshotDraftBeforeOptionChange, ], @@ -784,17 +889,20 @@ export function NewThreadComposer({ ); 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, @@ -1047,11 +1155,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( () => @@ -1059,15 +1184,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, }), [ @@ -1075,13 +1192,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 seedInitialPrompt = promptDraft.restoreIfEmpty; const focusPromptBox = useCallback(() => { setLocalPromptBoxFocusRequest((current) => (current ?? 0) + 1); @@ -1146,7 +1315,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 @@ -1185,7 +1355,14 @@ export function NewThreadComposer({ setIsCopyingAttachments(false); } }, - [onProjectChange, projectId, promptDraft, snapshotDraftBeforeOptionChange], + [ + isResourceDraft, + onProjectChange, + projectId, + promptDraft, + requestedProjectId, + snapshotDraftBeforeOptionChange, + ], ); const reuseEnvironmentId = @@ -1324,28 +1501,76 @@ export function NewThreadComposer({ supportsServiceTier, ], ); + const seedSubmissionEnvironment = + seed?.environment?.type === "provider" + ? { ...seed.environment, machine: seed.environment.machine ?? undefined } + : (seed?.environment ?? null); const submissionEnvironment = 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, sendAt: number | null) => { const submittedDraft = promptDraft.getCurrent(); @@ -1391,10 +1616,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); @@ -1407,6 +1632,7 @@ export function NewThreadComposer({ }, [ clearReuseEnvironment, + isResourceDraft, executionInputSources, onSubmit, permissionMode, @@ -1442,10 +1668,13 @@ export function NewThreadComposer({ (value: string) => { if (!hasPromptOptionValueChanged(selectedProviderId, value)) return; snapshotDraftBeforeOptionChange(); + if (isResourceDraft) setPreferredProviderId(value); setSelectedProviderId(value); }, [ + isResourceDraft, selectedProviderId, + setPreferredProviderId, setSelectedProviderId, snapshotDraftBeforeOptionChange, ], @@ -1454,33 +1683,61 @@ export function NewThreadComposer({ (value: string) => { if (!hasPromptOptionValueChanged(selectedModel, value)) return; snapshotDraftBeforeOptionChange(); + if (isResourceDraft) setPreferredModel(value); setSelectedModel(value); }, - [selectedModel, setSelectedModel, snapshotDraftBeforeOptionChange], + [ + isResourceDraft, + selectedModel, + setPreferredModel, + setSelectedModel, + snapshotDraftBeforeOptionChange, + ], ); const handleReasoningChange = useCallback( (value: ReasoningLevel) => { if (!hasPromptOptionValueChanged(reasoningLevel, value)) return; snapshotDraftBeforeOptionChange(); + if (isResourceDraft) setPreferredReasoning(value); setReasoningLevel(value); }, - [reasoningLevel, setReasoningLevel, snapshotDraftBeforeOptionChange], + [ + isResourceDraft, + reasoningLevel, + setPreferredReasoning, + setReasoningLevel, + snapshotDraftBeforeOptionChange, + ], ); const handlePermissionChange = useCallback( (value: PermissionMode) => { if (!hasPromptOptionValueChanged(permissionMode, value)) return; snapshotDraftBeforeOptionChange(); + if (isResourceDraft) setPreferredPermission(value); setPermissionMode(value); }, - [permissionMode, setPermissionMode, snapshotDraftBeforeOptionChange], + [ + isResourceDraft, + permissionMode, + setPreferredPermission, + setPermissionMode, + snapshotDraftBeforeOptionChange, + ], ); const handleServiceTierChange = useCallback( (value: ServiceTier | undefined) => { if (!hasPromptOptionValueChanged(serviceTier, value)) return; snapshotDraftBeforeOptionChange(); + if (isResourceDraft) setPreferredServiceTier(value ?? ""); setServiceTier(value); }, - [serviceTier, setServiceTier, snapshotDraftBeforeOptionChange], + [ + isResourceDraft, + serviceTier, + setPreferredServiceTier, + setServiceTier, + snapshotDraftBeforeOptionChange, + ], ); const handleWorktreeChange = useCallback( (environmentId: string) => { @@ -1618,7 +1875,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, @@ -1689,6 +1951,8 @@ export function NewThreadComposer({ isCopyingAttachments, isLoadingModels, isProjectless, + isResourceDraft, + requestedProjectId, isSubmitting, isUploading, modelLoadError, @@ -1759,6 +2023,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 531ce2d601c..0f1ec0430dc 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -34,7 +34,10 @@ import { MACOS_WINDOW_DRAG_CLASS, shouldUseMacosDesktopChrome, } from "@/lib/bb-desktop"; -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 { @@ -55,8 +58,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"; const SIDEBAR_FOOTER_ACTION_CLASS = cn( COARSE_POINTER_CHILD_ICON_BUTTON_CLASS, @@ -84,12 +85,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 [desktopInfo] = useState(getBbDesktopInfo); @@ -110,10 +120,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 ce19995dfb6..ae5e2b05ad4 100644 --- a/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx +++ b/apps/app/src/components/sidebar/BuiltInSidebarNavigation.tsx @@ -55,8 +55,8 @@ export function BuiltInSidebarNavigation({ ), disabled: onNewChat === undefined, 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; + }) =>
{children}
, +})); +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 182692530ac..45b630498ef 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -48,11 +48,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 { @@ -136,12 +136,14 @@ import { SidebarHeaderControls, } from "./SidebarHeaderControls"; export { TopLevelSidebarSection } from "./TopLevelSidebarSection"; +import { LifecycleFilterMenu } from "@/components/thread/LifecycleFilterMenu"; +import { sidebarLifecycleFilterAtom } from "./sidebarLifecycleFilter"; +import { DraftRows } from "./DraftRows"; +import { ArchivedRows } from "./ArchivedRows"; import { useAppCommandRunner, useAppCommandShortcut, } from "@/components/commands/AppCommandProvider"; -import { usePaneContentSplitIndicator } from "./paneContentSplitIndicator"; -import { SplitPaneMiniMap } from "./SplitPaneMiniMap"; import { renderBuiltInSidebarSection, SortableSidebarSection, @@ -493,16 +495,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 ( @@ -1375,7 +1366,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; @@ -1448,16 +1439,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) => { @@ -1870,71 +1858,24 @@ function ProjectListComponent({ }} > - ( - - )} - renderChronological={() => ( - <> - - - )} - renderProject={() => ( - <> - + + + {lifecycles.includes("drafts") ? ( + + ) : null} + {lifecycles.includes("active") ? ( + ( + - - )} - /> + )} + renderChronological={() => ( + <> + + + )} + renderProject={() => ( + <> + + + )} + /> + ) : null} + {lifecycles.includes("archived") ? ( + + ) : null} {sectionCreateDialog} {sectionRenameDialogContent} diff --git a/apps/app/src/components/sidebar/ProjectListActionButtons.test.tsx b/apps/app/src/components/sidebar/ProjectListActionButtons.test.tsx index ee39779a1f0..c071d648c8c 100644 --- a/apps/app/src/components/sidebar/ProjectListActionButtons.test.tsx +++ b/apps/app/src/components/sidebar/ProjectListActionButtons.test.tsx @@ -2,7 +2,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ProjectListSearchThreadsAction } from "./ProjectList"; +import { + ProjectListNewThreadAction, + ProjectListSearchThreadsAction, +} from "./ProjectList"; const mocks = vi.hoisted(() => ({ dispatch: vi.fn(), @@ -67,3 +70,31 @@ describe("ProjectListSearchThreadsAction", () => { expect(mocks.dispatch).toHaveBeenCalledWith("thread.search", button); }); }); + +describe("ProjectListNewThreadAction", () => { + it("creates on each activation and delegates modifier activation to the fresh-draft split action", () => { + const onNewChat = vi.fn(); + const openInSplit = vi.fn(); + render( + , + ); + const button = screen.getByRole("button", { name: "New thread" }); + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button, { metaKey: true }); + expect(onNewChat).toHaveBeenCalledTimes(2); + expect(openInSplit).toHaveBeenCalledOnce(); + }); + + it("opens normally when a split action is unavailable", () => { + const onNewChat = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "New thread" }), { + ctrlKey: true, + }); + expect(onNewChat).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx b/apps/app/src/components/sidebar/ProjectListSectionHeader.test.tsx index df6584c63b5..6392e08ebf6 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", () => { @@ -201,7 +206,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 7c2dc52e419..e4b0d714ec9 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(), })); @@ -119,7 +120,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(); @@ -256,9 +271,7 @@ describe("SidebarNavigationRegion", () => { ).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Plugins" })); - expect(screen.getByTestId("pathname").textContent).toBe( - "/plugins", - ); + expect(screen.getByTestId("pathname").textContent).toBe("/plugins"); expect( screen .getByRole("button", { name: "Plugins" }) @@ -266,9 +279,7 @@ describe("SidebarNavigationRegion", () => { ).toBe("page"); fireEvent.click(screen.getByRole("button", { name: "Skills" })); - expect(screen.getByTestId("pathname").textContent).toBe( - "/skills", - ); + expect(screen.getByTestId("pathname").textContent).toBe("/skills"); }); it("delegates and falls back after a crash without owner remounts", () => { diff --git a/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx b/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx index a179372d27b..64112dc37cc 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( () => @@ -177,12 +187,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 67fa4841e2a..6d9f59c1c76 100644 --- a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx @@ -578,7 +578,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 0ded6e32964..b098ffb5192 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -44,7 +44,10 @@ import { setPluginThreadRowStatus, } from "@/lib/plugin-thread-row-status"; 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"; @@ -194,7 +197,7 @@ function renderSplitThreadRow({ { type: "pane", paneId: "pane-compose", - content: { kind: "new-thread" }, + content: { kind: "new-thread", draftId: "drf_sidebarfixture" }, }, ], }, @@ -219,7 +222,9 @@ afterEach(() => { resetPluginThreadRowStatusesForTest(); 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 8130f6fa77d..c25b17ea9dc 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; @@ -80,11 +80,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, @@ -125,25 +137,41 @@ export function usePaneContentSplitActions() { 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?.(); + const navigationOptions = + existing !== null ? { replace: true } : undefined; navigate( - routeForContent(content), - existing !== null ? { replace: true } : undefined, + routeForContent(resolvedContent), + isFreshDraft + ? { ...navigationOptions, state: { focusPrompt: true } } + : navigationOptions, ); }, }); 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 273fc50a8a3..3b0e47be9ea 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 32ff3fff3da..d67fabba5de 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/mutations/thread-state-mutations.ts b/apps/app/src/hooks/mutations/thread-state-mutations.ts index dde72c06962..809f5af07bd 100644 --- a/apps/app/src/hooks/mutations/thread-state-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-state-mutations.ts @@ -311,6 +311,7 @@ export function useUnarchiveThread() { const queryClient = useQueryClient(); return useMutation({ + mutationKey: ["unarchive-thread"], meta: { errorMessage: "Failed to unarchive thread.", }, 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.ts b/apps/app/src/hooks/useCreateThreadInEnvironment.ts index 4776951c9d8..5095a3ef575 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: { reuseEnvironmentId: environmentId }, - }); - }, [environmentId, navigate, projectId, setRootComposeProjectId]); + openNewDraft( + { projectId }, + { + state: { 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 fdf213a4d24..55d031e5cea 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -226,6 +226,7 @@ export function useThreadCreationOptions( initialReasoningLevel, initialServiceTier, preferReadyProviderWhenUnset = false, + preserveUnavailableSelections = false, preferenceProjectId, resolveProviderRouting, resetKey, @@ -339,7 +340,7 @@ export function useThreadCreationOptions( }); const canResolveReadyProvider = executionOptionsQueryEnabled && - scope === "new-thread" && + (scope === "new-thread" || preserveUnavailableSelections) && preferReadyProviderWhenUnset && selectedProviderIdBeforeReadyFallback.length === 0; const shouldResolveReadyProvider = @@ -377,38 +378,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) @@ -416,7 +426,7 @@ export function useThreadCreationOptions( return rawSelectedProviderId; } return providers[0]?.id ?? ""; - }, [providers, rawSelectedProviderId]); + }, [preserveUnavailableSelections, providers, rawSelectedProviderId]); const { setValue: setStoredSelectedModel, value: storedSelectedModel } = usePromptBoxModelPreference(effectiveProviderId); @@ -493,7 +503,7 @@ export function useThreadCreationOptions( ]); const routedCeiling = executionOptionsQuery.isPlaceholderData ? undefined - : executionOptionsQuery.data?.permissionCeiling; + : executionOptionsData?.permissionCeiling; const permissionCeiling: PermissionMode = routedCeiling ?? routedHostCeiling ?? "full"; const allowedPermissionModes = useMemo( @@ -541,36 +551,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; @@ -757,10 +773,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( @@ -775,6 +789,7 @@ export function useThreadCreationOptions( }); return; } + touchedThreadFieldsRef.current.add("reasoningLevel"); setLocalProvidersUsingDefaults((current) => { if (!current.has(effectiveProviderId)) return current; const next = new Set(current); @@ -793,8 +808,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 2486ccdecfa..be3b1baf9c6 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 { useCallback, useContext, @@ -53,7 +54,6 @@ import { AUTOMATIONS_PLUGIN_ID, getPluginPanelRoutePath, getProjectComposeRoutePath, - getRootComposeRoutePath, getThreadRoutePath, AUTOMATION_EDIT_ROUTE_PATH, } from "@/lib/route-paths"; @@ -290,6 +290,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) => { @@ -326,18 +327,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 c7357cf60ca..cb413410d44 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 9bec9806dde..32195018242 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 b5a18744fc4..e144223359a 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(); @@ -281,6 +285,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) { @@ -376,6 +386,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 1126832e9d9..853d7b2aab2 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -1,10 +1,15 @@ -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, @@ -15,6 +20,8 @@ import { type ThreadListEntry, } from "@bb/domain"; import type { + DraftContent, + DraftOptions, SidebarBootstrapResponse, TerminalSession, } from "@bb/server-contract"; @@ -64,7 +71,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, @@ -84,7 +103,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"; @@ -139,6 +157,7 @@ import { toFilePreviewLineRange, } from "@/lib/live-file-navigation"; import { + rootComposeProjectIdAtom, useRootComposeProjectId, useSetRootComposeProjectId, } from "@/lib/root-compose-selection"; @@ -503,126 +522,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, - 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 }, - ); - 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" + } /> )} @@ -631,24 +1037,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) { @@ -700,10 +1112,6 @@ function RootComposeSurface({ [promptDraft.storageKey, sharedPluginComposerHost], ); - useEffect(() => { - if (projectId === rootComposeProjectId) return; - setRootComposeProjectId(projectId); - }, [projectId, rootComposeProjectId, setRootComposeProjectId]); useEffect( () => subscribeComposerFocusRequests(promptDraft.storageKey, () => { @@ -724,50 +1132,47 @@ 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) return; - setStartedComposing(true); - setPromptDraft({ text: initialPrompt, mentions: [], attachments: [] }); - navigate( - getRootComposeRoutePath() + stripInitialPromptFromSearch(location.search), - { replace: true, state: location.state }, - ); - }, [ - location.search, - location.state, - navigate, - setPromptDraft, - setStartedComposing, - ]); - useEffect(() => { - const sectionTarget = readRootComposeSectionTargetFromLocationState( - location.state, - ); - const reuseEnvironmentId = readReuseEnvironmentIdFromLocationState( - location.state, - ); + if (!ownsLocation || consumedLocationKey.current === location.key) return; + const queryPrompt = readInitialPromptFromSearch(location.search); + const initialPrompt = readInitialPromptFromLocationState(location.state); const nextForkSeed = readForkThreadCreateSeedFromLocationState( location.state, ); const nextHandoffSeed = readThreadHandoffCreateSeedFromLocationState( location.state, ); - if (!hasSingleUseRootComposeTargetState(location.state)) return; - if (shouldStartComposingFromLocationState(location.state)) { - setStartedComposing(true); + const reuseEnvironmentId = readReuseEnvironmentIdFromLocationState( + location.state, + ); + 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; + consumedLocationKey.current = location.key; + if (queryPrompt !== null) { + setPromptDraft({ text: queryPrompt, mentions: [], attachments: [] }); } - 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 && nextHandoffSeed === null) { setForkSeed(nextForkSeed); - setRootComposeProjectId(nextForkSeed.projectId); setProviderModelReasoning(nextForkSeed); setPermissionMode(nextForkSeed.permissionMode); setServiceTier(nextForkSeed.serviceTier); @@ -776,64 +1181,59 @@ function RootComposeSurface({ ); } if (nextHandoffSeed !== null) { - setStartedComposing(true); setRootComposeProjectId(nextHandoffSeed.projectId); - setForkSeed(null); if (nextHandoffSeed.environmentId !== null) { + setReuseEnvironment(nextHandoffSeed.environmentId); seedEnvironmentSelectionValue( encodeReuseValue(nextHandoffSeed.environmentId), ); } setPromptDraft(buildThreadHandoffPromptDraft(nextHandoffSeed)); } - navigate(getRootComposeRoutePath() + location.search, { - replace: true, - state: null, - }); + if (initialPrompt !== null) { + const nextDraft = { text: initialPrompt, mentions: [], attachments: [] }; + if (shouldReplaceInitialPromptFromLocationState(location.state)) { + setPromptDraft(nextDraft); + } else { + restorePromptDraftIfEmpty(nextDraft); + } + } + if ( + shouldFocus || + queryPrompt !== null || + initialPrompt !== null || + nextHandoffSeed !== 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, setPromptDraft, setProviderModelReasoning, + setReuseEnvironment, setRootComposeProjectId, setRootComposeSectionId, setServiceTier, setStartedComposing, ]); - useEffect(() => { - const initialPrompt = readInitialPromptFromLocationState(location.state); - if (initialPrompt === null) return; - const nextDraft = { text: initialPrompt, mentions: [], attachments: [] }; - if (shouldReplaceInitialPromptFromLocationState(location.state)) { - setPromptDraft(nextDraft); - } else { - restorePromptDraftIfEmpty(nextDraft); - } - navigate(getRootComposeRoutePath() + location.search, { - replace: true, - state: { focusPrompt: true }, - }); - }, [ - location.search, - location.state, - navigate, - restorePromptDraftIfEmpty, - setPromptDraft, - ]); - 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 }), @@ -1812,12 +2212,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, @@ -1924,16 +2325,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.` @@ -1958,7 +2359,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 97beb704b12..bcd5d0657ec 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, @@ -84,10 +80,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 { @@ -1073,7 +1066,7 @@ export function ExperimentsSettingsSection({ } export function SettingsView() { - const navigate = useNavigate(); + const openNewDraft = useOpenNewThreadDraft(); const themePreference = useThemePreference(); const systemConfigQuery = useSystemConfig(); const { hasDaemon } = useHostDaemon(); @@ -1164,12 +1157,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 ( - + {JSON.stringify(location.state)} ); @@ -901,12 +913,14 @@ describe("SkillsLibrary registry detail lifecycle", () => { renderDom( - - } /> - } /> - - - + + + } /> + } /> + + + + , ); @@ -940,6 +954,7 @@ describe("SkillsLibrary registry detail lifecycle", () => { const state = JSON.parse( (await screen.findByTestId("location-state")).textContent ?? "null", ); + expect(screen.getByTestId("location-state").dataset.draftId).toBeTruthy(); expect(state).toEqual({ focusPrompt: true, initialPrompt: buildRegistrySkillReferencePrompt(registrySkill), diff --git a/apps/app/src/views/SplitWorkspaceRoute.test.tsx b/apps/app/src/views/SplitWorkspaceRoute.test.tsx index f3c7f44da9d..c80f225c052 100644 --- a/apps/app/src/views/SplitWorkspaceRoute.test.tsx +++ b/apps/app/src/views/SplitWorkspaceRoute.test.tsx @@ -5,8 +5,11 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PaneContent } from "@/lib/split-layout"; +import { getDraftRoutePath } from "@/lib/draft-route"; import SplitWorkspaceRoute from "./SplitWorkspaceRoute"; +const DRAFT_ID = "drf_workspace_test"; + const workspaceLifecycle = vi.hoisted(() => ({ mounts: 0, unmounts: 0 })); vi.mock("./thread-detail/SplitThreadArea", () => ({ @@ -17,7 +20,16 @@ vi.mock("./thread-detail/SplitThreadArea", () => ({ workspaceLifecycle.unmounts += 1; }; }, []); - return {routeContent.kind}; + return ( + + {routeContent.kind} + + ); }, })); @@ -35,7 +47,9 @@ function NavigationControls() { const navigate = useNavigate(); return ( <> - + @@ -52,7 +66,7 @@ describe("SplitWorkspaceRoute", () => { it("preserves the workspace mount across focus-driven page URL changes", () => { render( - + } /> @@ -61,6 +75,7 @@ describe("SplitWorkspaceRoute", () => { ); expect(screen.getByTestId("route-content").textContent).toBe("new-thread"); + expect(screen.getByTestId("route-content").dataset.draftId).toBe(DRAFT_ID); fireEvent.click(screen.getByRole("button", { name: "plugin" })); expect(screen.getByTestId("route-content").textContent).toBe( diff --git a/apps/app/src/views/SplitWorkspaceRoute.tsx b/apps/app/src/views/SplitWorkspaceRoute.tsx index ef9eaa12de7..9e12c793d43 100644 --- a/apps/app/src/views/SplitWorkspaceRoute.tsx +++ b/apps/app/src/views/SplitWorkspaceRoute.tsx @@ -13,13 +13,15 @@ import { } from "@/lib/route-paths"; import type { PaneContent } from "@/lib/split-layout"; import { useRouteState } from "@/hooks/useRouteState"; -import { LegacyProjectComposeRedirect } from "./RootComposeView"; +import { parseDraftRouteId } from "@/lib/draft-route"; +import { + RootComposeView, + LegacyProjectComposeRedirect, +} from "./RootComposeView"; import { SplitThreadArea } from "./thread-detail/SplitThreadArea"; disableGlobalCursorStyles(); -const ROOT_COMPOSE_CONTENT = { kind: "new-thread" } as const; - const PluginsView = lazy(() => import("./ToolsView").then((m) => ({ default: m.PluginsView })), ); @@ -43,7 +45,8 @@ export default function SplitWorkspaceRoute() { const routeContent = useMemo(() => { if (location.pathname === APP_ROOT_ROUTE_PATH) { - return ROOT_COMPOSE_CONTENT; + const draftId = parseDraftRouteId(location.search); + return draftId === null ? null : { kind: "new-thread", draftId }; } if (isThreadView && projectId && threadId) { return { kind: "thread", projectId, threadId }; @@ -64,6 +67,7 @@ export default function SplitWorkspaceRoute() { detailPluginId, isThreadView, location.pathname, + location.search, panelPath, pluginId, pluginSubPath, @@ -77,6 +81,9 @@ export default function SplitWorkspaceRoute() { if (legacyProjectId) { return ; } + if (location.pathname === APP_ROOT_ROUTE_PATH && routeContent === null) { + return ; + } if (routeContent === null) { return ; } diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index af5027f7333..5deb8d52f91 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -1,3 +1,4 @@ +import { useOpenNewThreadDraft } from "@/hooks/useOpenNewThreadDraft"; import { Suspense, useCallback, @@ -50,7 +51,6 @@ import { SKILLS_ROUTE_PATH, getPluginDetailRoutePath, getPluginsRoutePath, - getRootComposeRoutePath, } from "@/lib/route-paths"; import { getToolsOwnedCollectionRoutePath } from "@/components/tools/tools-navigation"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -138,6 +138,7 @@ function PluginsToolView({ function PluginDetailToolView({ pluginId }: { pluginId: string }) { const navigate = useNavigate(); + const openNewDraft = useOpenNewThreadDraft(); const location = useLocation(); const [deleteTarget, setDeleteTarget] = useState(null); const [installTarget, setInstallTarget] = @@ -215,18 +216,21 @@ function PluginDetailToolView({ pluginId }: { pluginId: string }) { : null; const handleEditPlugin = useCallback( (plugin: PluginListItem) => { - navigate(getRootComposeRoutePath(), { - state: { - focusPrompt: true, - initialPrompt: buildPluginEditThreadPrompt({ - name: plugin.name ?? plugin.id, - path: plugin.rootDir, - }), - replaceInitialPrompt: true, + openNewDraft( + {}, + { + state: { + focusPrompt: true, + initialPrompt: buildPluginEditThreadPrompt({ + name: plugin.name ?? plugin.id, + path: plugin.rootDir, + }), + replaceInitialPrompt: true, + }, }, - }); + ); }, - [navigate], + [openNewDraft], ); const handleOpenPluginSource = useCallback( (plugin: PluginListItem) => { diff --git a/apps/app/src/views/root-compose-draft.test.ts b/apps/app/src/views/root-compose-draft.test.ts new file mode 100644 index 00000000000..007dda97b0e --- /dev/null +++ b/apps/app/src/views/root-compose-draft.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { draftContentSchema } from "@bb/server-contract"; +import type { NewThreadComposerSubmission } from "@/components/promptbox/NewThreadComposer"; +import type { PaneContent, SplitLayout } from "@/lib/split-layout"; +import { + ownsRootComposeLocation, + replaceRootDraftOrigin, + rootDraftComposerSeed, + rootDraftSubmissionContent, +} from "./root-compose-draft"; + +const destination: PaneContent = { + kind: "thread", + projectId: "proj_origin", + threadId: "thr_created", +}; + +function layout(focusedPaneId: string): SplitLayout { + return { + focusedPaneId, + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [ + { + type: "pane", + paneId: "first", + content: { kind: "new-thread", draftId: "drf_firstdraft" }, + }, + { + type: "pane", + paneId: "second", + content: { kind: "new-thread", draftId: "drf_seconddraft" }, + }, + ], + }, + }; +} + +const origin = { draftId: "drf_firstdraft", paneId: "first", hadLayout: true }; + +describe("root draft ownership", () => { + it("consumes initial prompt and location seeds only for the focused draft matching the route", () => { + const location = { + pathname: "/", + search: "?draft=drf_firstdraft&initialPrompt=hello", + }; + expect(ownsRootComposeLocation("drf_firstdraft", true, location)).toBe( + true, + ); + expect(ownsRootComposeLocation("drf_firstdraft", false, location)).toBe( + false, + ); + expect(ownsRootComposeLocation("drf_seconddraft", true, location)).toBe( + false, + ); + expect( + ownsRootComposeLocation("drf_firstdraft", true, { + pathname: "/threads/thr_current", + search: location.search, + }), + ).toBe(false); + }); + + it("replaces only the sending pane and preserves focus when another pane is selected during submission", () => { + const current = layout("second"); + const result = replaceRootDraftOrigin({ + layout: current, + origin, + destination, + currentRouteDraftId: "drf_seconddraft", + }); + expect(result.navigate).toBe(false); + expect(result.layout?.focusedPaneId).toBe("second"); + expect(result.layout?.root).toMatchObject({ + children: [ + { paneId: "first", content: destination }, + { + paneId: "second", + content: { kind: "new-thread", draftId: "drf_seconddraft" }, + }, + ], + }); + }); + + it("does not replace a closed or repurposed originating pane", () => { + const current = layout("first"); + const changed = replaceRootDraftOrigin({ + layout: current, + origin, + destination: { kind: "new-thread", draftId: "drf_otherdraft" }, + currentRouteDraftId: origin.draftId, + }).layout; + const result = replaceRootDraftOrigin({ + layout: changed, + origin, + destination, + currentRouteDraftId: "drf_otherdraft", + }); + expect(result).toEqual({ layout: changed, navigate: false }); + expect( + replaceRootDraftOrigin({ + layout: current, + origin: { ...origin, paneId: "closed" }, + destination, + currentRouteDraftId: origin.draftId, + }), + ).toEqual({ layout: current, navigate: false }); + expect( + replaceRootDraftOrigin({ + layout: null, + origin, + destination, + currentRouteDraftId: origin.draftId, + }), + ).toEqual({ layout: null, navigate: false }); + }); + + it("navigates a canonical unsplit root only while its original draft still owns the URL", () => { + const canonical = { ...origin, paneId: null, hadLayout: false }; + expect( + replaceRootDraftOrigin({ + layout: null, + origin: canonical, + destination, + currentRouteDraftId: origin.draftId, + }).navigate, + ).toBe(true); + expect( + replaceRootDraftOrigin({ + layout: null, + origin: canonical, + destination, + currentRouteDraftId: "drf_seconddraft", + }).navigate, + ).toBe(false); + expect( + replaceRootDraftOrigin({ + layout: layout("second"), + origin: canonical, + destination, + currentRouteDraftId: origin.draftId, + }).navigate, + ).toBe(false); + }); +}); + +describe("root draft content", () => { + it("restores unavailable choices without replacing them with another draft's defaults", () => { + const content = draftContentSchema.parse({ + projectId: "proj_removed", + options: { + providerId: "provider_removed", + model: "model_removed", + reasoningLevel: "high", + permissionMode: "accept-edits", + environment: { + type: "provider", + environmentProviderId: "environment_removed", + machine: null, + inputs: null, + }, + }, + }); + expect(rootDraftComposerSeed(content.options)).toMatchObject({ + providerId: "provider_removed", + model: "model_removed", + reasoningLevel: "high", + permissionMode: "accept-edits", + environment: content.options.environment, + }); + expect(content.projectId).toBe("proj_removed"); + }); + + it("copies submitted choices while preserving destination, fork metadata, and the current prompt", () => { + const content = draftContentSchema.parse({ + projectId: null, + sectionId: "section_saved", + prompt: { text: "Current text" }, + options: { + sourceThreadId: "thr_source", + sourceSeqEnd: 12, + originKind: "fork", + title: "Saved title", + }, + }); + const request: NewThreadComposerSubmission = { + projectId: "proj_fallback", + providerId: "provider_selected", + model: "model_selected", + reasoningLevel: "high", + permissionMode: "accept-edits", + environment: { type: "reuse", environmentId: "env_saved" }, + executionInputSources: {}, + input: [], + sendAt: 1000, + }; + const result = rootDraftSubmissionContent(content, request); + expect(result.projectId).toBeNull(); + expect(result.sectionId).toBe("section_saved"); + expect(result.prompt.text).toBe("Current text"); + expect(result.options).toMatchObject({ + sourceThreadId: "thr_source", + sourceSeqEnd: 12, + originKind: "fork", + title: "Saved title", + model: "model_selected", + sendAt: 1000, + }); + }); +}); diff --git a/apps/app/src/views/root-compose-draft.ts b/apps/app/src/views/root-compose-draft.ts new file mode 100644 index 00000000000..dd1015bc419 --- /dev/null +++ b/apps/app/src/views/root-compose-draft.ts @@ -0,0 +1,105 @@ +import type { DraftContent, DraftOptions } from "@bb/server-contract"; +import type { + NewThreadComposerSeed, + NewThreadComposerSubmission, +} from "@/components/promptbox/NewThreadComposer"; +import { parseDraftRouteId } from "@/lib/draft-route"; +import { + replaceDraftPaneContent, + type PaneContent, + type SplitLayout, +} from "@/lib/split-layout"; + +export interface RootDraftOrigin { + draftId: string; + paneId: string | null; + hadLayout: boolean; +} + +export function rootComposeRouteDraftId(location: { + pathname: string; + search: string; +}): string | null { + return location.pathname === "/" ? parseDraftRouteId(location.search) : null; +} + +export function ownsRootComposeLocation( + draftId: string, + isFocused: boolean, + location: { pathname: string; search: string }, +): boolean { + return isFocused && rootComposeRouteDraftId(location) === draftId; +} + +export function rootDraftComposerSeed( + options: DraftOptions, +): NewThreadComposerSeed { + return { + providerId: options.providerId ?? undefined, + model: options.model ?? undefined, + reasoningLevel: options.reasoningLevel ?? undefined, + serviceTier: options.serviceTier ?? undefined, + permissionMode: options.permissionMode ?? undefined, + environment: options.environment ?? undefined, + }; +} + +export function rootDraftSubmissionContent( + content: DraftContent, + request: NewThreadComposerSubmission, +): DraftContent { + return { + ...content, + options: { + ...content.options, + providerId: request.providerId, + model: request.model, + reasoningLevel: request.reasoningLevel, + serviceTier: request.serviceTier ?? null, + permissionMode: request.permissionMode, + environment: + request.environment.type === "provider" + ? { + ...request.environment, + machine: request.environment.machine ?? null, + } + : request.environment, + sendAt: request.sendAt ?? null, + }, + }; +} + +export function replaceRootDraftOrigin({ + layout, + origin, + destination, + currentRouteDraftId, +}: { + layout: SplitLayout | null; + origin: RootDraftOrigin; + destination: PaneContent; + currentRouteDraftId: string | null; +}): { layout: SplitLayout | null; navigate: boolean } { + if (!origin.hadLayout) { + return { + layout, + navigate: layout === null && currentRouteDraftId === origin.draftId, + }; + } + if (layout === null || origin.paneId === null) { + return { layout, navigate: false }; + } + const next = replaceDraftPaneContent( + layout, + origin.paneId, + origin.draftId, + destination, + ); + return { + layout: next, + navigate: + next !== layout && + layout.focusedPaneId === origin.paneId && + currentRouteDraftId === origin.draftId, + }; +} diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx index 038321a2579..cc5af88a0b0 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx @@ -20,6 +20,8 @@ import { import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { afterEach, describe, expect, it, vi } from "vitest"; import { threadQueryKey } from "@/hooks/queries/query-keys"; +import { useThread } from "@/hooks/queries/thread-queries"; +import { useUnarchiveThread } from "@/hooks/mutations/thread-state-mutations"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import type { SplitLayout } from "@/lib/split-layout"; import { PaneContext } from "./PaneContext"; @@ -56,7 +58,10 @@ vi.mock("@/hooks/useRealtimeSubscription", () => ({ vi.mock("@/lib/sdk", () => ({ sdk: { - threads: { get: () => new Promise(() => {}) }, + threads: { + get: () => new Promise(() => {}), + unarchive: () => pendingArchive!.promise, + }, }, })); @@ -71,10 +76,12 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ vi.mock("./ThreadDetailView", () => ({ ThreadDetailView: ({ threadId }: { threadId: string }) => { const pane = useContext(PaneContext); + const { data: thread } = useThread(threadId); return (
); }, @@ -118,6 +125,19 @@ function LocationProbe() { return
{location.pathname}
; } +function UnarchiveHarness() { + const mutation = useUnarchiveThread(); + return ( + + ); +} + function twoPaneLayout(focusedPaneId: "pane-1" | "pane-2"): SplitLayout { const content = (threadId: string) => ({ kind: "thread" as const, @@ -138,14 +158,14 @@ function twoPaneLayout(focusedPaneId: "pane-1" | "pane-2"): SplitLayout { }; } -function renderArchiveScenario() { +function renderArchiveScenario(initialArchivedAt: number | null = null) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); for (const id of ["thr-a", "thr-b"]) { queryClient.setQueryData(threadQueryKey(id), { id, - archivedAt: null, + archivedAt: id === "thr-b" ? initialArchivedAt : null, deletedAt: null, }); } @@ -158,6 +178,7 @@ function renderArchiveScenario() { + , @@ -174,9 +195,58 @@ afterEach(() => { cleanup(); pendingArchive = null; window.localStorage.clear(); + window.sessionStorage.clear(); }); describe("SplitThreadArea archive pruning", () => { + it("keeps the archived pane and its focus when unarchiving is rejected", async () => { + deferArchive(); + const queryClient = renderArchiveScenario(ARCHIVED_AT); + expect(await screen.findByTestId("pane-thr-b")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("unarchive")); + await waitFor(() => expect(archivedAtOf(queryClient, "thr-b")).toBeNull()); + await act(async () => + pendingArchive!.reject(new Error("unarchive failed")), + ); + + await waitFor(() => + expect(archivedAtOf(queryClient, "thr-b")).toBe(ARCHIVED_AT), + ); + expect(screen.getByTestId("pane-thr-b").dataset.focused).toBe("true"); + expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); + expect(screen.getByTestId("location").textContent).toBe("/threads/thr-b"); + }); + + it("keeps an archived pane when its thread first loads, but closes it after unarchiving and archiving again", async () => { + deferArchive(); + const queryClient = renderArchiveScenario(ARCHIVED_AT); + expect(await screen.findByTestId("pane-thr-b")).toBeTruthy(); + expect(archivedAtOf(queryClient, "thr-b")).toBe(ARCHIVED_AT); + + await act(async () => { + queryClient.setQueryData(threadQueryKey("thr-b"), { + id: "thr-b", + archivedAt: null, + deletedAt: null, + }); + }); + await waitFor(() => + expect(screen.getByTestId("pane-thr-b").dataset.archived).toBe("false"), + ); + fireEvent.click(screen.getByTestId("archive")); + await waitFor(() => + expect(archivedAtOf(queryClient, "thr-b")).toBe(ARCHIVED_AT), + ); + expect(screen.getByTestId("pane-thr-b")).toBeTruthy(); + + await act(async () => pendingArchive!.resolve()); + + await waitFor(() => expect(screen.queryByTestId("pane-thr-b")).toBeNull()); + expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); + expect(screen.getByTestId("location").textContent).toBe("/threads/thr-a"); + }); + it("restores the pane, focus, and URL when a deferred archive is rejected", async () => { deferArchive(); const queryClient = renderArchiveScenario(); diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index 16a76f0d98a..61f69ab7ec6 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -449,7 +449,10 @@ const pluginGuideContent: PaneContent = { subPath: "", }; -const newThreadContent: PaneContent = { kind: "new-thread" }; +const newThreadContent: PaneContent = { + kind: "new-thread", + draftId: "drf_navigation_test", +}; function pluginContent(panelPath: string): PaneContent { return { @@ -1770,6 +1773,26 @@ describe("SplitThreadArea", () => { ).toEqual([screen.getByTestId("pane-thr-a")]); }); + it("retains an already archived thread when it replaces an active thread in the same pane", async () => { + threadStore.set("thr-c", { archivedAt: 123, deletedAt: null }); + const store = renderSplitArea({ + path: threadPath("thr-b"), + layout: twoPaneLayout("pane-2"), + externalTo: threadPath("thr-c"), + }); + expect(await screen.findByTestId("pane-thr-b")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("external-nav")); + + expect(await screen.findByTestId("pane-thr-c")).toBeTruthy(); + expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); + expect(screen.queryByTestId("pane-thr-b")).toBeNull(); + expect(store.get(splitLayoutAtom)?.focusedPaneId).toBe("pane-2"); + expect(screen.getByTestId("location").textContent).toBe( + threadPath("thr-c"), + ); + }); + it("focuses an already-open pane instead of duplicating on external navigation", async () => { renderSplitArea({ path: threadPath("thr-b"), @@ -2244,16 +2267,15 @@ describe("SplitThreadArea", () => { expect(screen.queryByTestId("pane-thr-b")).toBeNull(); }); - it("prunes a stale (archived) pane from a restored split", async () => { + it("retains an already archived pane from a restored split", async () => { threadStore.set("thr-b", { archivedAt: 123, deletedAt: null }); - renderSplitArea({ + const store = renderSplitArea({ path: threadPath("thr-a"), layout: twoPaneLayout("pane-1"), }); - await waitFor(() => { - expect(screen.queryByTestId("pane-thr-b")).toBeNull(); - }); + expect(await screen.findByTestId("pane-thr-b")).toBeTruthy(); + expect(store.get(splitLayoutAtom)).toEqual(twoPaneLayout("pane-1")); expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); expect(screen.getByTestId("location").textContent).toBe( threadPath("thr-a"), diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 121b62c52f1..1009b43d5a3 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -760,6 +760,7 @@ function SplitTree(props: SplitTreeProps) { {} {node.content.kind === "thread" ? ( props.onPruneStalePane(node.paneId)} /> @@ -971,7 +972,7 @@ function StandalonePaneContent({ return ; } if (content.kind === "new-thread") { - return ; + return ; } if (content.kind === "plugin-detail") { return ; @@ -1175,7 +1176,7 @@ function NonThreadPaneContent({ )} > {content.kind === "new-thread" ? ( - + ) : content.kind === "plugin-detail" ? ( ) : ( @@ -1445,6 +1446,10 @@ interface PaneStaleWatcherProps { function PaneStaleWatcher({ threadId, onStale }: PaneStaleWatcherProps) { const { data: thread, isSuccess, isError, error } = useThread(threadId); + const hasObservedUnarchived = useRef(false); + const unarchivesInFlight = useIsMutating({ + mutationKey: ["unarchive-thread"], + }); const archivesInFlight = useIsMutating({ predicate: (mutation) => mutation.options.meta?.lifecycleOperation === "archive_thread", @@ -1458,17 +1463,31 @@ function PaneStaleWatcher({ threadId, onStale }: PaneStaleWatcherProps) { thread !== undefined && thread.archivedAt !== null && archivesInFlight === 0; - const isStale = isGone || isDeleted || isConfirmedArchived; + const isUnarchived = + isSuccess && thread !== undefined && thread.archivedAt === null; const onStaleRef = useRef(onStale); useEffect(() => { onStaleRef.current = onStale; }, [onStale]); useEffect(() => { - if (isStale) { + if (isUnarchived && unarchivesInFlight === 0) { + hasObservedUnarchived.current = true; + } + if ( + isGone || + isDeleted || + (isConfirmedArchived && hasObservedUnarchived.current) + ) { onStaleRef.current(); } - }, [isStale]); + }, [ + isConfirmedArchived, + isDeleted, + isGone, + isUnarchived, + unarchivesInFlight, + ]); return null; } diff --git a/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts b/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts index 0f564ba57ff..be563562810 100644 --- a/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts +++ b/apps/app/src/views/thread-detail/splitThreadNavigation.test.ts @@ -41,20 +41,25 @@ function eightPaneLayout(): SplitLayout { } describe("mixed page navigation", () => { - it("keeps New Thread as a singleton and focuses its existing pane", () => { + it("focuses the existing pane for the same draft identity", () => { const withCompose = splitPane(twoPaneLayout(), "pane-2", "bottom", { kind: "new-thread", + draftId: "drf_navigation_test", }); const after = reconcileLayoutForContent(withCompose, { kind: "new-thread", + draftId: "drf_navigation_test", }); expect(listPanes(after.root)).toHaveLength(3); expect(after.focusedPaneId).toBe( - findPaneByContent(after.root, { kind: "new-thread" })?.paneId, + findPaneByContent(after.root, { + kind: "new-thread", + draftId: "drf_navigation_test", + })?.paneId, ); - expect(focusedPaneRoute(after)).toBe("/"); + expect(focusedPaneRoute(after)).toBe("/?draft=drf_navigation_test"); }); it("updates a plugin pane's subpath without duplicating the panel", () => { diff --git a/apps/app/src/views/thread-detail/splitThreadNavigation.ts b/apps/app/src/views/thread-detail/splitThreadNavigation.ts index 1b37d6ee5ee..765376d4d7c 100644 --- a/apps/app/src/views/thread-detail/splitThreadNavigation.ts +++ b/apps/app/src/views/thread-detail/splitThreadNavigation.ts @@ -12,12 +12,12 @@ import { } from "@/lib/split-layout"; import { decideThreadDrop, type SplitZone } from "@/lib/split-drag"; import type { PaneContent, SplitLayout } from "@/lib/split-layout"; +import { getDraftRoutePath, parseDraftRouteId } from "@/lib/draft-route"; import { matchPath } from "react-router-dom"; import { APP_ROOT_ROUTE_PATH, getPluginDetailRoutePath, getPluginPanelRoutePath, - getRootComposeRoutePath, getThreadRoutePath, PLUGIN_DETAIL_ROUTE_PATH, PLUGIN_PANEL_ROUTE_PATH, @@ -59,7 +59,7 @@ export function paneContentRoute(content: PaneContent): string { return getThreadRoutePath(content); } if (content.kind === "new-thread") { - return getRootComposeRoutePath(); + return getDraftRoutePath(content.draftId); } if (content.kind === "plugin-detail") { return getPluginDetailRoutePath({ pluginId: content.pluginId }); @@ -71,9 +71,11 @@ export function paneContentRoute(content: PaneContent): string { }); } -export function paneContentForPathname(pathname: string): PaneContent | null { +export function paneContentForPathname(path: string): PaneContent | null { + const [pathname = "", search = ""] = (path.split("#")[0] ?? "").split("?"); if (pathname === APP_ROOT_ROUTE_PATH) { - return { kind: "new-thread" }; + const draftId = parseDraftRouteId(search); + return draftId === null ? null : { kind: "new-thread", draftId }; } const thread = matchPath( { path: SPLITTABLE_THREAD_ROUTE_PATH, end: false }, diff --git a/apps/cli/src/__tests__/command-output/draft.test.ts b/apps/cli/src/__tests__/command-output/draft.test.ts index 5cf9c27ef83..43addc80849 100644 --- a/apps/cli/src/__tests__/command-output/draft.test.ts +++ b/apps/cli/src/__tests__/command-output/draft.test.ts @@ -48,6 +48,24 @@ describe("bb thread draft commands", () => { const register: CommandRegistrar = (program) => registerDraftCommands(program.command("thread"), () => "http://server"); + it("opens a saved identity and passes optional placement without inferring a thread target", async () => { + const post = vi.fn(async () => ({ delivered: 0 })); + stubServerApi({ "v1.drafts.:id.open.$post": post }); + await runCommand( + ["thread", "draft", "open", draft.id, "--split", "right", "--json"], + register, + ); + expect(post).toHaveBeenCalledWith({ + param: { id: draft.id }, + json: { split: "right" }, + }); + expect(JSON.parse(collectLogLines(vi.mocked(console.log))[0]!)).toEqual({ + draftId: draft.id, + split: "right", + delivered: 0, + }); + }); + it("prints complete generated and caller-supplied identities for reuse", async () => { const ids = [ "drf_c4f849da-569e-4822-bb8d-b143438b20b7", diff --git a/apps/cli/src/commands/thread/draft.ts b/apps/cli/src/commands/thread/draft.ts index 096ed6ae93c..2fc5ed9c5b3 100644 --- a/apps/cli/src/commands/thread/draft.ts +++ b/apps/cli/src/commands/thread/draft.ts @@ -1,6 +1,10 @@ import { readFile } from "node:fs/promises"; import { Command } from "commander"; -import { draftContentSchema, type DraftContent } from "@bb/server-contract"; +import { + draftContentSchema, + threadOpenSplitSchema, + type DraftContent, +} from "@bb/server-contract"; import { action } from "../../action.js"; import { createCliBbSdk } from "../../client.js"; import { renderBorderlessTable } from "../../table.js"; @@ -117,6 +121,43 @@ export function registerDraftCommands( .command("draft") .description("Manage saved drafts with or without an app connected"); + draft + .command("open ") + .description("Open a saved draft in connected apps") + .option( + "--split ", + "Open right, down, left, top, or replace; edge placements add panes through pane 8, then replace the focused pane", + ) + .option("--json", "Print machine-readable JSON output") + .action( + action( + async ( + draftId: string, + opts: JsonOutputOptions & { split?: string }, + ) => { + const split = + opts.split === undefined + ? undefined + : threadOpenSplitSchema.parse(opts.split); + const result = await createCliBbSdk(getUrl()).drafts.open({ + draftId, + ...(split === undefined ? {} : { split }), + }); + if ( + outputJson(opts, { + draftId, + split: split ?? "replace", + delivered: result.delivered, + }) + ) + return; + console.log(`Draft: ${draftId}`); + console.log(`Split: ${split ?? "replace"}`); + console.log(`Delivered: ${result.delivered}`); + }, + ), + ); + contentOptions(draft.command("create")) .description("Save a draft without starting a thread") .option("--id ", "Stable creation identity for retry or import") diff --git a/apps/server/src/routes/drafts.ts b/apps/server/src/routes/drafts.ts index 14fc26aa827..3224939f1d3 100644 --- a/apps/server/src/routes/drafts.ts +++ b/apps/server/src/routes/drafts.ts @@ -32,6 +32,15 @@ export function registerDraftRoutes(app: Hono, deps: AppDeps): void { }); const routes = publicApiRoutes.drafts; + post(routes.open, (context, payload) => { + const draft = requireDraft(deps, context.req.param("id")); + const delivered = deps.hub.notifyDraftOpen( + draft.id, + payload.split ?? "replace", + ); + return context.json({ delivered }); + }); + post(routes.create, (context, payload) => context.json(createDraftResource(deps, payload), 201), ); diff --git a/apps/server/src/ws/hub.ts b/apps/server/src/ws/hub.ts index 41deb9bbdc4..bfe0f0fc7fa 100644 --- a/apps/server/src/ws/hub.ts +++ b/apps/server/src/ws/hub.ts @@ -25,6 +25,7 @@ import { serverMessageSchema, terminalServerMessageSchema, threadOpenSignalSchema, + draftOpenSignalSchema, threadPaneActionSignalSchema, type ThreadPaneAction, type ThreadOpenFile, @@ -818,6 +819,22 @@ export class NotificationHub implements DbNotifier { } } + notifyDraftOpen(draftId: string, split: ThreadOpenSplit): number { + const payload = JSON.stringify( + draftOpenSignalSchema.parse({ + type: "draft-open", + draftId, + split, + }), + ); + let delivered = 0; + for (const socket of this.clientKeysBySocket.keys()) { + socket.send(payload); + delivered += 1; + } + return delivered; + } + notifyThreadOpen( thread: { projectId: string; threadId: string }, request: { split: ThreadOpenSplit; file: ThreadOpenFile | null }, diff --git a/apps/server/test/public/public-draft-open.test.ts b/apps/server/test/public/public-draft-open.test.ts new file mode 100644 index 00000000000..63b1ec1a1b7 --- /dev/null +++ b/apps/server/test/public/public-draft-open.test.ts @@ -0,0 +1,96 @@ +import { getStoredDraft } from "@bb/db"; +import { draftCreateResponseSchema } from "@bb/server-contract"; +import { describe, expect, it } from "vitest"; +import { createMockHubSocket } from "../helpers/mock-hub-socket.js"; +import { readJson } from "../helpers/json.js"; +import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; + +async function createDraft(harness: TestAppHarness): Promise { + const response = await harness.app.request("/api/v1/drafts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: { prompt: { text: "Saved work" } } }), + }); + expect(response.status).toBe(201); + return draftCreateResponseSchema.parse(await readJson(response)).id; +} + +function open( + harness: TestAppHarness, + id: string, + body: unknown, + origin?: string, +) { + return harness.app.request(`/api/v1/drafts/${id}/open`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(origin ? { origin } : {}), + }, + body: JSON.stringify(body), + }); +} + +describe("public draft open", () => { + it("opens without an app and broadcasts placement without changing the resource", async () => { + await withTestHarness(async (harness) => { + const id = await createDraft(harness); + const before = getStoredDraft(harness.db, id); + expect(await readJson(await open(harness, id, {}))).toEqual({ + delivered: 0, + }); + const first = createMockHubSocket(); + const second = createMockHubSocket(); + harness.deps.hub.registerClient(first); + harness.deps.hub.registerClient(second); + for (const split of [ + undefined, + "right", + "down", + "left", + "top", + "replace", + ]) { + const response = await open( + harness, + id, + split === undefined ? {} : { split }, + ); + expect(response.status).toBe(200); + expect(await readJson(response)).toEqual({ delivered: 2 }); + expect(JSON.parse(first.messages.at(-1)!)).toEqual({ + type: "draft-open", + draftId: id, + split: split ?? "replace", + }); + } + expect(first.messages).toEqual(second.messages); + expect(getStoredDraft(harness.db, id)).toEqual(before); + }); + }); + + it("rejects missing, deleted, invalid, and cross-origin opens without sending", async () => { + await withTestHarness(async (harness) => { + const id = await createDraft(harness); + const socket = createMockHubSocket(); + harness.deps.hub.registerClient(socket); + expect((await open(harness, "drf_missing_draft", {})).status).toBe(404); + expect((await open(harness, id, { split: "diagonal" })).status).toBe(400); + expect( + (await open(harness, id, { browserId: "unimplemented" })).status, + ).toBe(400); + expect( + (await open(harness, id, {}, "https://untrusted.example")).status, + ).toBe(403); + const deletion = await harness.app.request(`/api/v1/drafts/${id}`, { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ expectedRevision: 1 }), + }); + expect(deletion.status).toBe(200); + socket.messages.length = 0; + expect((await open(harness, id, {})).status).toBe(410); + expect(socket.messages).toHaveLength(0); + }); + }); +}); diff --git a/apps/server/test/public/public-ui-preferences.test.ts b/apps/server/test/public/public-ui-preferences.test.ts index 61c370217f2..ac647879be4 100644 --- a/apps/server/test/public/public-ui-preferences.test.ts +++ b/apps/server/test/public/public-ui-preferences.test.ts @@ -29,6 +29,57 @@ async function resetPreference( } describe("public ui preferences", () => { + it("persists nonempty sidebar lifecycle choices and resets without changing organization", async () => { + await withTestHarness(async (harness) => { + expect(await readJson(await listPreferences(harness))).toMatchObject({ + preferences: { + "sidebar.lifecycleFilter": { value: ["active"], revision: 0 }, + }, + }); + await putPreference(harness, "sidebar.organizationMode", { + expectedRevision: 0, + value: "machine", + }); + const saved = await putPreference(harness, "sidebar.lifecycleFilter", { + expectedRevision: 0, + value: ["drafts", "archived"], + }); + expect(saved.status).toBe(200); + expect(await readJson(await listPreferences(harness))).toMatchObject({ + preferences: { + "sidebar.lifecycleFilter": { + value: ["drafts", "archived"], + revision: 1, + }, + "sidebar.organizationMode": { value: "machine", revision: 1 }, + }, + }); + for (const value of [[], ["active", "active"], ["closed"]]) { + expect( + ( + await putPreference(harness, "sidebar.lifecycleFilter", { + expectedRevision: 1, + value, + }) + ).status, + ).toBe(400); + } + expect( + await readJson( + await resetPreference(harness, "sidebar.lifecycleFilter"), + ), + ).toMatchObject({ + value: ["active"], + revision: 2, + }); + expect(await readJson(await listPreferences(harness))).toMatchObject({ + preferences: { + "sidebar.organizationMode": { value: "machine", revision: 1 }, + }, + }); + }); + }); + it("adds sort direction without replacing an existing sort field and can reset it", async () => { await withTestHarness(async (harness) => { expect( diff --git a/docs/configuration.md b/docs/configuration.md index 1cf3cf8623b..2d3bab43642 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -616,23 +616,29 @@ schema, a default, and a revision that increments on every write. Writes name the revision they expect and receive `409 ui_preference_conflict` when another client wrote first, so a stale window cannot silently clobber a newer value. -| Key | Value | -| --------------------------------- | --------------------------------------------------- | -| `sidebar.organizationMode` | `project`, `chronological`, or `machine` | -| `sidebar.chronologicalSort` | `updated`, `created`, `alpha`, or `none` | -| `sidebar.sectionOrder` | Section id list for **By project** | -| `sidebar.manualSectionOrder` | Section id list for **Manually** | -| `sidebar.machineSectionOrder` | Section id list for **By machine** | -| `sidebar.collapsedSections` | Collapsed built-in sections (`pinned`, `threads`) | -| `sidebar.collapsedProjects` | Collapsed project ids | -| `sidebar.collapsedThreads` | Thread ids whose children are collapsed | -| `sidebar.collapsedEnvironments` | Collapsed environment ids | -| `sidebar.collapsedThreadSections` | Collapsed thread section ids | -| `sidebar.collapsedMachines` | Collapsed machine ids | -| `sidebar.pluginPanelOrder` | Navigation entry order | -| `sidebar.visiblePluginPanels` | Navigation entries shown, or `null` for every entry | -| `sidebar.navigationProvider` | Plugin key, `__automatic__`, or `__builtin__` | -| `sidebar.threadListProvider` | Plugin key, `__automatic__`, or `__builtin__` | +| Key | Value | +| --------------------------------- | ---------------------------------------------------------------------------- | +| `sidebar.lifecycleFilter` | Unique nonempty list of `active`, `drafts`, `archived`; default `["active"]` | +| `sidebar.organizationMode` | `project`, `chronological`, or `machine` | +| `sidebar.chronologicalSort` | `updated`, `created`, `alpha`, or `none` | +| `sidebar.sectionOrder` | Section id list for **By project** | +| `sidebar.manualSectionOrder` | Section id list for **Manually** | +| `sidebar.machineSectionOrder` | Section id list for **By machine** | +| `sidebar.collapsedSections` | Collapsed built-in sections (`pinned`, `threads`) | +| `sidebar.collapsedProjects` | Collapsed project ids | +| `sidebar.collapsedThreads` | Thread ids whose children are collapsed | +| `sidebar.collapsedEnvironments` | Collapsed environment ids | +| `sidebar.collapsedThreadSections` | Collapsed thread section ids | +| `sidebar.collapsedMachines` | Collapsed machine ids | +| `sidebar.pluginPanelOrder` | Navigation entry order | +| `sidebar.visiblePluginPanels` | Navigation entries shown, or `null` for every entry | +| `sidebar.navigationProvider` | Plugin key, `__automatic__`, or `__builtin__` | +| `sidebar.threadListProvider` | Plugin key, `__automatic__`, or `__builtin__` | + +The built-in thread list offers a lifecycle filter. Drafts appear above the +active tree; archived threads load in a paginated group only when selected. +At least one lifecycle stays selected. The filter does not alter plugin-owned +thread-list replacements. Resetting `sidebar.lifecycleFilter` restores Active. Read and write them with: diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 403f5bea433..19d7a8f0414 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.84"; +export const PLUGIN_SDK_VERSION = "0.4.85"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/domain/src/ui-preferences.ts b/packages/domain/src/ui-preferences.ts index edee376e377..88a79029a32 100644 --- a/packages/domain/src/ui-preferences.ts +++ b/packages/domain/src/ui-preferences.ts @@ -22,6 +22,17 @@ export type SidebarChronologicalSort = z.infer< typeof sidebarChronologicalSortSchema >; +const threadLifecycleSchema = z.enum(["active", "drafts", "archived"]); +export type ThreadLifecycle = z.infer; + +const threadLifecycleFilterSchema = z + .array(threadLifecycleSchema) + .min(1) + .max(3) + .refine((values) => new Set(values).size === values.length, { + message: "Thread lifecycle selections must be unique.", + }); + const collapsibleSidebarSectionIdSchema = z.enum(["pinned", "threads"]); const uiPreferenceStringSchema = z @@ -33,6 +44,7 @@ const uiPreferenceStringListSchema = z .max(UI_PREFERENCE_LIST_MAX_LENGTH); export const UI_PREFERENCE_KEYS = [ + "sidebar.lifecycleFilter", "sidebar.organizationMode", "sidebar.chronologicalSort", "sidebar.sortDirection", @@ -72,6 +84,11 @@ function defineUiPreference( } export const uiPreferenceDefinitions = { + "sidebar.lifecycleFilter": defineUiPreference( + threadLifecycleFilterSchema, + ["active"], + "Thread lifecycles shown in the built-in sidebar: active, drafts, and archived; select at least one.", + ), "sidebar.organizationMode": defineUiPreference( sidebarOrganizationModeSchema, "project", diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 10b100d538b..bb50409ce3e 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.84", + "version": "0.4.85", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/sdk/src/areas/drafts.ts b/packages/sdk/src/areas/drafts.ts index b602bf51c1e..f7ea0a2e6b8 100644 --- a/packages/sdk/src/areas/drafts.ts +++ b/packages/sdk/src/areas/drafts.ts @@ -2,6 +2,7 @@ import { draftCreateResponseSchema, draftDeleteResponseSchema, draftListResponseSchema, + draftOpenResponseSchema, draftSchema, draftSubmitResponseSchema, type Draft, @@ -10,12 +11,16 @@ import { type DraftDeleteRequest, type DraftDeleteResponse, type DraftListResponse, + type DraftOpenRequest, + type DraftOpenResponse, type DraftSubmitRequest, type DraftSubmitResponse, type DraftUpdateRequest, } from "@bb/server-contract"; import { signalRequestArgs, type CreateSdkAreaArgs } from "./common.js"; +export type DraftOpenArgs = DraftOpenRequest & { draftId: string }; +export type DraftOpenResult = DraftOpenResponse; export type DraftCreateArgs = DraftCreateRequest; export type DraftCreateResult = DraftCreateResponse; export type DraftDeleteArgs = DraftDeleteRequest & { draftId: string }; @@ -40,6 +45,7 @@ export type DraftSubmitArgs = DraftSubmitRequest & { draftId: string }; export type DraftSubmitResult = DraftSubmitResponse; export interface DraftsArea { + open(args: DraftOpenArgs): Promise; create(args?: DraftCreateArgs): Promise; list(args?: DraftListArgs): Promise; get(args: DraftGetArgs): Promise; @@ -51,6 +57,16 @@ export interface DraftsArea { export function createDraftsArea(args: CreateSdkAreaArgs): DraftsArea { const { transport } = args; return { + async open(input) { + const { draftId, ...json } = input; + const body = await transport.readJson( + transport.api.v1.drafts[":id"].open.$post({ + param: { id: draftId }, + json, + }), + ); + return draftOpenResponseSchema.parse(body); + }, async create(input = {}) { const body = await transport.readJson( transport.api.v1.drafts.$post({ json: input }), diff --git a/packages/sdk/test/drafts.test.ts b/packages/sdk/test/drafts.test.ts index 18f6f4c999a..006606dadce 100644 --- a/packages/sdk/test/drafts.test.ts +++ b/packages/sdk/test/drafts.test.ts @@ -104,6 +104,37 @@ function sdkWithResponses(responses: Response[]) { } describe("draft SDK", () => { + it("opens saved drafts through the server with optional placement and recipient counts", async () => { + const { sdk, fetch } = sdkWithResponses([ + Response.json({ delivered: 0 }), + Response.json({ delivered: 2 }), + ]); + await expect(sdk.drafts.open({ draftId: draft.id })).resolves.toEqual({ + delivered: 0, + }); + await expect( + sdk.drafts.open({ draftId: draft.id, split: "left" }), + ).resolves.toEqual({ delivered: 2 }); + expect( + fetch.mock.calls.map(([url, init]) => ({ + url: String(url), + method: init?.method, + body: JSON.parse(String(init?.body)), + })), + ).toEqual([ + { + url: `http://bb.test/api/v1/drafts/${draft.id}/open`, + method: "POST", + body: {}, + }, + { + url: `http://bb.test/api/v1/drafts/${draft.id}/open`, + method: "POST", + body: { split: "left" }, + }, + ]); + }); + it("exposes revision-required content mutations through every public entrypoint", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/server-contract/src/api/drafts.ts b/packages/server-contract/src/api/drafts.ts index f7156b0cc27..cfd53773b1d 100644 --- a/packages/server-contract/src/api/drafts.ts +++ b/packages/server-contract/src/api/drafts.ts @@ -15,7 +15,7 @@ import { projectDefaultEnvironmentSchema, reuseEnvironmentSchema, } from "./shared.js"; -import { threadCreateOriginSchema } from "./threads.js"; +import { threadCreateOriginSchema, threadOpenSplitSchema } from "./threads.js"; export const draftIdSchema = z.string().regex(/^drf_[A-Za-z0-9_-]{8,128}$/); @@ -142,3 +142,23 @@ export const draftSubmitResponseSchema = z.object({ draft: draftSchema.nullable(), }); export type DraftSubmitResponse = z.infer; + +export const draftOpenRequestSchema = z + .object({ + split: threadOpenSplitSchema.optional(), + }) + .strict(); +export type DraftOpenRequest = z.infer; + +export const draftOpenResponseSchema = z.object({ + delivered: z.number().int().nonnegative(), +}); +export type DraftOpenResponse = z.infer; + +export const draftOpenSignalLenientSchema = z.object({ + type: z.literal("draft-open"), + draftId: draftIdSchema, + split: threadOpenSplitSchema, +}); +export const draftOpenSignalSchema = draftOpenSignalLenientSchema.strict(); +export type DraftOpenSignal = z.infer; diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 2fdbb326025..3fc638b7d8e 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -7,6 +7,7 @@ import { draftCreateRequestSchema, draftDeleteRequestSchema, draftListQuerySchema, + draftOpenRequestSchema, draftSubmitRequestSchema, draftUpdateRequestSchema, type Draft, @@ -16,6 +17,8 @@ import { type DraftDeleteResponse, type DraftListQuery, type DraftListResponse, + type DraftOpenRequest, + type DraftOpenResponse, type DraftSubmitRequest, type DraftSubmitResponse, type DraftUpdateRequest, @@ -1085,6 +1088,12 @@ export const publicApiRoutes = { }, drafts: { + open: defineRoute({ + path: "/drafts/:id/open", + method: "post", + request: jsonRequest(draftOpenRequestSchema), + response: jsonResponse(), + }), create: defineRoute({ path: "/drafts", method: "post", diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 50939a1e727..54fd558ed55 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -247,6 +247,14 @@ Sort by selects a field, and selecting it again reverses its arrow/direction. The default preserves each field's original order (newest first for dates, A–Z for titles). For example: `bb settings ui set sidebar.sortDirection ascending`. +The built-in sidebar lifecycle filter selects Active, Drafts, and Archived. +`sidebar.lifecycleFilter` accepts a unique, nonempty JSON list containing +`active`, `drafts`, and/or `archived`; it defaults to `["active"]`. For example: +`bb settings ui set sidebar.lifecycleFilter '["active","drafts"]'`. +Drafts appear before active threads; Archived loads a paginated trailing group +only when selected. `bb settings ui reset sidebar.lifecycleFilter` restores +Active. Plugin-owned thread-list replacements keep their own controls. + Client-local UI preferences Some Settings values live only in the current browser/client. Sidebar width diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 6ec59924275..20cf4e12bcf 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -15,6 +15,7 @@ Saved drafts: [--content-file ] [--id ] bb thread draft list [--project ] [--query ] [--include-empty] [--limit ] [--offset ] + bb thread draft open [--split ] [--json] bb thread draft show (alias: bb thread draft get) bb thread draft update --expected-revision [--text ] [--project ] [--section ] @@ -66,7 +67,14 @@ Saved drafts: fails after reserving a thread, the error requires saving a new revision before an intentional new attempt. Keep local unsaved content after errors. - SDK parity: sdk.drafts.create/list/get/update/delete/submit. Use draftId for + Open broadcasts to connected apps and returns the number of recipients; zero + means no app received it. It never submits or changes the saved draft. + Ordinary open replaces the focused pane; --split right/down/left/top adds a + pane, up to pane 8, then replaces the focused pane. An already-open draft is + focused instead of duplicated. Compact apps use the focused pane. + + SDK open accepts {draftId, split?}; split defaults to replace. + SDK parity: sdk.drafts.create/list/get/update/delete/submit/open. Use draftId for ID operations, expectedRevision for mutations, and content for create/update. list accepts projectId, query, includeEmpty (boolean), limit and offset (numbers); list/get accept signal. SDK submit defaults origin to "sdk"; diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 5f6706d0725..fe64a0cbd52 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -26,6 +26,14 @@ every window and client sees the same value. - `bb settings ui reset [--json]` writes the default and advances the revision. +- `sidebar.lifecycleFilter` controls the built-in sidebar's Active, Drafts, + and Archived groups. It accepts a unique, nonempty JSON list of `active`, + `drafts`, and/or `archived`, with default `["active"]`. For example: + `bb settings ui set sidebar.lifecycleFilter '["active","drafts"]'`. + Drafts appear above the active tree; archived threads are paginated and + fetched only when selected. Reset restores Active. Plugin-owned thread-list + replacements are unaffected. + ## Keyboard shortcuts - `showKeyboardHints` defaults to true. Set it with diff --git a/plugins/bb-guide/skills/bb-cli/references/command-index.md b/plugins/bb-guide/skills/bb-cli/references/command-index.md index 74b26f880f9..f7d043aa615 100644 --- a/plugins/bb-guide/skills/bb-cli/references/command-index.md +++ b/plugins/bb-guide/skills/bb-cli/references/command-index.md @@ -34,6 +34,7 @@ This index lists every command path that the core CLI registers. Read the task-s - `bb thread draft list` - `bb thread draft show` - `bb thread draft get` +- `bb thread draft open` - `bb thread draft update` - `bb thread draft delete` - `bb thread draft submit` diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md index 2322d607d29..d4d46158606 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md @@ -13,6 +13,10 @@ are separate from an active thread's unsent follow-up. attachment names. Omit --project for all projects. Blank/option-only drafts require --include-empty. Use --limit (1–200, default 50) and --offset for pagination; JSON returns drafts and nextOffset. +- `bb thread draft open [--split right|down|left|top|replace]` opens in + connected apps and returns a recipient count (zero when no app is connected). + It defaults to replacing the focused pane; edge placements add up to 8 panes. + An already-open draft is focused. Opening never changes or submits the draft. - Read `bb thread draft show --json` (alias `get`) to obtain the revision and full content. `bb thread draft update --expected-revision --text "..."` preserves attachments, options and destination. --project and --section edit