diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 7032e9dcae..187f98be08 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -39,10 +39,11 @@ "painted' and 'route content painted'. SplitWorkspaceRoute is every thread,", "compose and plugin-panel page, so its closure is the second number that", "decides how slow bb feels on a phone. Same 10% ratchet; its forbiddenPackages", - "are the diff engine, math, file tree and terminal code that only a user", - "action needs.", - "The composer (tiptap/prosemirror) is visible on every thread page and is", - "allowed until it moves behind a first-focus handoff. Run", + "are the diff engine, math, file tree, terminal and editor code that only a", + "user action needs.", + "The composer (tiptap/prosemirror) loads behind PromptBox's first-focus", + "handoff: the shell chrome is eager, the editor graph is gated on", + "PromptBoxInternal (see onDemandPackages). Run", "`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx `", "to print the static chain that pulled a package into the closure." ], @@ -72,13 +73,45 @@ ], "onDemandPackages": { "@pierre/trees": "src/components/secondary-panel/ThreadStorageFileTree.tsx", + "@tiptap/core": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-blockquote": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-bold": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-code": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-code-block": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-document": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-hard-break": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-heading": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-horizontal-rule": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-italic": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-link": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-list": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-mention": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-paragraph": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-placeholder": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-strike": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-text": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extension-underline": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/extensions": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/react": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/starter-kit": "src/components/promptbox/PromptBoxInternal.tsx", + "@tiptap/suggestion": "src/components/promptbox/PromptBoxInternal.tsx", "katex": "src/components/ui/markdown-katex.ts", + "prosemirror-commands": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-dropcursor": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-gapcursor": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-history": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-keymap": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-model": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-schema-list": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-state": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-transform": "src/components/promptbox/PromptBoxInternal.tsx", + "prosemirror-view": "src/components/promptbox/PromptBoxInternal.tsx", "rehype-katex": "src/components/ui/markdown-katex.ts" }, "routeClosures": { "SplitWorkspaceRoute": { - "maxBytes": 2268430, - "maxBrotliBytes": 605332, + "maxBytes": 1765479, + "maxBrotliBytes": 489779, "forbiddenPackages": [ "@pierre/diffs", "@pierre/theming", @@ -88,10 +121,17 @@ "@shikijs/engine-oniguruma", "@shikijs/langs", "@shikijs/vscode-textmate", + "@tiptap/core", + "@tiptap/pm", + "@tiptap/react", + "@tiptap/starter-kit", "@xterm/xterm", "katex", "mermaid", "oniguruma-to-es", + "prosemirror-model", + "prosemirror-state", + "prosemirror-view", "rehype-katex", "shiki" ] diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx index b2ec85bebe..10ff42a2db 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx @@ -51,8 +51,8 @@ vi.mock("@bb/shared-ui/hooks/use-pointer-coarse", () => ({ usePointerCoarse: () => mocks.isPointerCoarse, })); -vi.mock("@/components/promptbox/PromptBoxInternal", () => ({ - PromptBoxInternal: ({ +vi.mock("@/components/promptbox/PromptBox", () => ({ + PromptBox: ({ footerStart, compact, onSubmit, diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 7bd8871707..246db1f6c5 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -33,13 +33,13 @@ import { useComposerExtensionController, } from "@/components/plugin/ComposerExtensionHost"; import { - PromptBoxInternal, + PromptBox, type AttachmentsConfig, type HistoryConfig, type PromptBoxAction, type PromptBoxHandle, type TypeaheadConfig, -} from "@/components/promptbox/PromptBoxInternal"; +} from "@/components/promptbox/PromptBox"; import { usePromptVoice } from "@/components/promptbox/usePromptVoice"; import { PermissionModePicker } from "@/components/pickers/PermissionModePicker"; import { @@ -61,9 +61,7 @@ import { shouldDisablePermissionPickerForActivePromptMode, } from "@bb/client-core"; -type PromptBoxWithScrollAnchorProps = ComponentProps< - typeof PromptBoxInternal -> & { +type PromptBoxWithScrollAnchorProps = ComponentProps & { scrollToBottomOnModifierSubmit?: boolean; scrollToBottomOnSubmit?: boolean; }; @@ -101,7 +99,7 @@ function PromptBoxWithScrollAnchor({ : {}), }; return ( - - void; + onChangeSpy?: (value: string, mentions: PromptTextMention[]) => void; + onAttachFiles?: (files: File[]) => void; + promptBoxRef?: RefObject; +} + +function ControlledPromptBox({ + initialValue = "", + autoFocus = false, + focusEndKey, + onSubmit = () => {}, + onChangeSpy, + onAttachFiles, + promptBoxRef, +}: HarnessProps) { + const [draft, setDraft] = useState<{ + value: string; + mentions: PromptTextMention[]; + }>({ value: initialValue, mentions: [] }); + return ( + { + onChangeSpy?.(value, mentions); + setDraft({ value, mentions }); + }} + onSubmit={onSubmit} + autoFocus={autoFocus} + mentionMenuPlacement="bottom" + typeahead={makeTypeahead()} + {...(onAttachFiles ? { attachments: { items: [], onAttachFiles } } : {})} + {...(promptBoxRef ? { promptBoxRef } : {})} + {...(focusEndKey !== undefined ? { focusEndKey } : {})} + /> + ); +} + +function queryMountedEditor(): HTMLElement | null { + // The shell's preview is a contenteditable="false" .ProseMirror stand-in; + // only the real tiptap editor is contenteditable="true". + return document.querySelector( + '.ProseMirror[contenteditable="true"]', + ); +} + +function getInterimInput(): HTMLTextAreaElement { + const interim = document.querySelector( + "[data-promptbox-interim-input]", + ); + if (!interim) throw new Error("interim input not rendered"); + return interim; +} + +async function waitForMountedEditor(): Promise { + await waitFor(() => { + expect(queryMountedEditor()).not.toBeNull(); + }); + const editor = queryMountedEditor(); + if (!editor) throw new Error("editor did not mount"); + return editor; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("PromptBox first-focus handoff", () => { + it("parks the composer as a shell and mounts the editor on first tap with the draft intact", async () => { + render(); + + // Shell state: the saved draft is visible immediately, no editor mounted. + const preview = document.querySelector("[data-promptbox-shell-preview]"); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toContain("Saved draft"); + expect(queryMountedEditor()).toBeNull(); + + fireEvent.focus(getInterimInput()); + + const editor = await waitForMountedEditor(); + expect(editor.textContent).toContain("Saved draft"); + expect( + document.querySelector("[data-promptbox-shell-preview]"), + ).toBeNull(); + }); + + it("streams keystrokes typed before the mount into the draft and the editor", async () => { + const onChangeSpy = vi.fn(); + render(); + + const interim = getInterimInput(); + fireEvent.focus(interim); + fireEvent.input(interim, { target: { value: "hi there" } }); + + // The buffer flushes into the controlled draft on every input event. + expect(onChangeSpy).toHaveBeenLastCalledWith("hi there", []); + + const editor = await waitForMountedEditor(); + expect(editor.textContent).toContain("hi there"); + }); + + it("lands a text paste made before the mount in the editor after the mount", async () => { + const onChangeSpy = vi.fn(); + render(); + + const interim = getInterimInput(); + fireEvent.focus(interim); + fireEvent.paste(interim, { + clipboardData: { + items: [], + getData: (type: string) => + type === "text/plain" ? "pasted\r\nbefore mount" : "", + }, + }); + + expect(onChangeSpy).toHaveBeenLastCalledWith("pasted\nbefore mount", []); + + const editor = await waitForMountedEditor(); + expect(editor.textContent).toContain("pasted"); + expect(editor.textContent).toContain("before mount"); + }); + + it("routes a file paste made before the mount to the attachments handler", () => { + const onAttachFiles = vi.fn(); + render(); + + const file = new File(["png-bytes"], "shot.png", { type: "image/png" }); + const interim = getInterimInput(); + fireEvent.focus(interim); + fireEvent.paste(interim, { + clipboardData: { + items: [{ kind: "file", getAsFile: () => file }], + getData: () => "", + }, + }); + + expect(onAttachFiles).toHaveBeenCalledWith([file]); + }); + + it("realizes the editor and moves focus into it for a programmatic focus request", async () => { + const promptBoxRef = createRef(); + render(); + expect(queryMountedEditor()).toBeNull(); + + act(() => { + promptBoxRef.current?.focusEnd(); + }); + + const editor = await waitForMountedEditor(); + await waitFor(() => { + expect(document.activeElement).toBe(editor); + }); + }); + + it("realizes the editor when focusEndKey changes (the thread view's focus bus)", async () => { + const { rerender } = render(); + expect(queryMountedEditor()).toBeNull(); + + rerender(); + + await waitForMountedEditor(); + }); + + it("realizes the editor on mount when autoFocus applies (fine pointer)", async () => { + render(); + + await waitForMountedEditor(); + }); + + it("submits on Enter from the interim surface before the editor exists", () => { + const onSubmit = vi.fn(); + render(); + + const interim = getInterimInput(); + fireEvent.focus(interim); + fireEvent.input(interim, { target: { value: "ship it" } }); + fireEvent.keyDown(interim, { key: "Enter" }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/promptbox/PromptBox.tsx b/apps/app/src/components/promptbox/PromptBox.tsx new file mode 100644 index 0000000000..c514f41ba9 --- /dev/null +++ b/apps/app/src/components/promptbox/PromptBox.tsx @@ -0,0 +1,566 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, + type ClipboardEvent as ReactClipboardEvent, + type FocusEvent as ReactFocusEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { COARSE_POINTER_TEXT_BASE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { usePluginComposerHost } from "@/components/plugin/plugin-composer-host"; +import { useComposerInputLock } from "@/lib/plugin-sdk-hooks"; +import type { + PromptBoxHandle, + PromptBoxInternalProps, +} from "./PromptBoxInternal"; +import { + DEFAULT_PROMPTBOX_PLACEHOLDER, + isPromptBoxChromeTarget, + PromptBoxShell, +} from "./PromptBoxShell"; +import { appendPromptActionToDraft } from "./prompt-action-draft"; +import type { PromptBoxAction } from "./PromptBoxActionsMenu"; + +// Callers import the composer contract from this module so the tiptap editor +// stays out of their static closure. The type re-exports are erased at build +// time; the only runtime edge to PromptBoxInternal is the dynamic import gate +// below (named in bundle-budget.json's onDemandPackages). +export type { + AttachmentsConfig, + HistoryConfig, + PromptBoxHandle, + PromptBoxSubmissionConfig, + PromptVoiceConfig, + TypeaheadCommandConfig, + TypeaheadConfig, + TypeaheadMentionConfig, +} from "./PromptBoxInternal"; +export type { PromptBoxAction } from "./PromptBoxActionsMenu"; + +/** Public composer props: the internal editor's, minus the handoff-only prop. */ +export type PromptBoxProps = Omit; + +type PromptBoxInternalModule = typeof import("./PromptBoxInternal"); + +let loadedInternalModule: PromptBoxInternalModule | null = null; +let internalModulePromise: Promise | null = null; + +function loadPromptBoxInternalModule(): Promise { + internalModulePromise ??= import("./PromptBoxInternal").then((module) => { + loadedInternalModule = module; + return module; + }); + return internalModulePromise; +} + +const INTERNAL_PREFETCH_IDLE_TIMEOUT_MS = 2_500; + +/** + * Warms the editor chunk off the route's critical path so the first tap's + * handoff is usually a mount, not a fetch. Prefers an idle callback (the + * route chunk has been fetched and evaluated by then); browsers without + * `requestIdleCallback` get a plain timeout. Returns a cancel function. + */ +function schedulePromptBoxInternalPrefetch(): () => void { + if (loadedInternalModule !== null || typeof window === "undefined") { + return () => {}; + } + let idleHandle: number | null = null; + let timeoutHandle: number | null = null; + const run = () => { + idleHandle = null; + timeoutHandle = null; + void loadPromptBoxInternalModule(); + }; + if (typeof window.requestIdleCallback === "function") { + idleHandle = window.requestIdleCallback(run, { + timeout: INTERNAL_PREFETCH_IDLE_TIMEOUT_MS, + }); + } else { + timeoutHandle = window.setTimeout(run, INTERNAL_PREFETCH_IDLE_TIMEOUT_MS); + } + return () => { + if (idleHandle !== null) window.cancelIdleCallback(idleHandle); + if (timeoutHandle !== null) window.clearTimeout(timeoutHandle); + }; +} + +/** + * The composer behind a first-focus handoff. + * + * Renders the dumb PromptBoxShell (closed-state chrome, draft preview, action + * row — zero tiptap) until the first compose intent: a tap/focus on the text + * region, a paste or drop, a prompt action, or a programmatic focus request + * (`promptBoxRef.focusEnd()` / a `focusEndKey` change / desktop `autoFocus`). + * Intent starts the dynamic import of PromptBoxInternal and mounts it when it + * resolves; once realized it stays mounted for the life of this component. + * + * While the chunk loads, an invisible interim `