From 57bec4fc583a2b6b999ffa33d80315bd576898a2 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 17:31:29 +0200 Subject: [PATCH 1/3] Extract the composer's closed-state chrome into a tiptap-free PromptBoxShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tiptap/prosemirror graph (261.9 KB brotli) sits in SplitWorkspaceRoute's static closure because the composer chrome and the editor live in one module. Step 1 of the first-focus handoff (plan 008): extract the closed-state look — frame, placeholder, draft preview, action row, send button — into PromptBoxShell with zero tiptap imports. The shell's preview surface is a `.ProseMirror`-shaped contenteditable=false div, so the existing placeholder and compact-truncation CSS in app.css applies unchanged; draft text and mention pills render from the controlled value, so a saved draft shows immediately. Shared chrome (PromptSubmitButton, frame classes, chrome-target selector) and the editorless prompt-action append (new prompt-action-draft.ts) move out of PromptBoxInternal, which now imports them — the dependency is one-way so future features land in the internal module. PromptBoxInternal also grows the takeFocusOnCreate handoff prop the wrapper will use in step 2. A Ladle story renders the shell beside the mounted editor's closed state for the parity check (empty, saved draft, mention pill, compact). Co-Authored-By: Claude Fable 5 --- .../promptbox/PromptBoxInternal.tsx | 204 ++--- .../promptbox/PromptBoxShell.stories.tsx | 178 ++++ .../components/promptbox/PromptBoxShell.tsx | 773 ++++++++++++++++++ .../promptbox/prompt-action-draft.ts | 102 +++ 4 files changed, 1099 insertions(+), 158 deletions(-) create mode 100644 apps/app/src/components/promptbox/PromptBoxShell.stories.tsx create mode 100644 apps/app/src/components/promptbox/PromptBoxShell.tsx create mode 100644 apps/app/src/components/promptbox/prompt-action-draft.ts diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 6f05f7de03..235de16515 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -43,12 +43,6 @@ import { import { canLoadMoreCommandResults } from "@/components/promptbox/mentions/mention-menu-scroll"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@bb/shared-ui/tooltip"; import { ComposerActionsSlot } from "@/components/plugin/PluginComposerActions"; import { useResolvedComposerEditor } from "@/components/plugin/composer-slot-hooks"; import { @@ -118,11 +112,24 @@ import { MentionMenu, type TypeaheadSuggestion } from "./mentions/MentionMenu"; import { parsePromptMentionClipboardElement } from "./mentions/prompt-mention-clipboard"; import { ComposerEditorSlot } from "./ComposerEditorSlot"; import { QueuedEditorTypeaheadLayoutContext } from "./queued-editor-typeahead-layout"; +import { + COLLAPSING_GRID_CLASS, + COMPACT_PROMPT_ACTION_BUTTON_CLASS, + DEFAULT_PROMPTBOX_PLACEHOLDER, + isPromptBoxChromeTarget, + PROMPTBOX_MIN_HEIGHT, + PromptSubmitButton, + type PromptBoxCompactConfig, + type PromptBoxEditorLayout, +} from "./PromptBoxShell"; +import { + appendPromptActionToDraft, + promptActionCommandFromAction, + promptActionCommandSerializedText, + type PromptActionCommand, +} from "./prompt-action-draft"; -const PROMPTBOX_MIN_HEIGHT = 68; const PROMPTBOX_SELECTION_REVEAL_MARGIN = 12; -const COMPACT_PROMPT_ACTION_BUTTON_CLASS = - "size-8 p-0 transition-all [&_svg]:size-4"; const RICH_PASTE_BLOCK_TAGS = new Set([ "ADDRESS", "ARTICLE", @@ -180,10 +187,6 @@ function hasWhitespaceAfterPosition( return nextNode.type.name === "hardBreak"; } -type PromptBoxEditorLayout = "thread" | "root-compose"; - -const COLLAPSING_GRID_CLASS = - "grid transition-[grid-template-rows] duration-[180ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"; const VOICE_ACTION_TRANSITION_MS = 180; type VoiceActionTransition = "entering" | "active" | "exiting"; @@ -213,66 +216,6 @@ export interface PromptBoxSubmissionConfig { onModifierSubmit?: () => void; } -interface PromptSubmitButtonProps { - canSubmit: boolean; - className: string; - disabledReason: string | undefined; - isCompact: boolean; - isSubmitting: boolean; - onClick: (event: ReactMouseEvent) => void; - onPointerDown: (event: ReactPointerEvent) => void; - title: string; -} - -function PromptSubmitButton({ - canSubmit, - className, - disabledReason, - isCompact, - isSubmitting, - onClick, - onPointerDown, - title, -}: PromptSubmitButtonProps) { - const button = ( - - ); - - if (!disabledReason) return button; - - return ( - - - - - {button} - - - {disabledReason} - - - ); -} - /** * The `@`-mention half of {@link TypeaheadConfig}. Unchanged from the prior * `MentionsConfig` surface other than living under `typeahead.mention`. @@ -360,11 +303,6 @@ export interface AttachmentsConfig { projectId?: string; } -interface PromptBoxCompactConfig { - isCompact: boolean; - placeholder?: string; -} - export interface HistoryConfig { currentDraft: PromptDraftState; entries: readonly PromptDraftState[]; @@ -400,7 +338,7 @@ export type { PromptBoxAction } from "./PromptBoxActionsMenu"; type MentionMenuPlacement = "top" | "bottom"; -interface PromptBoxInternalProps { +export interface PromptBoxInternalProps { id?: string; value: string; mentionRanges: readonly PromptTextMention[]; @@ -475,6 +413,13 @@ interface PromptBoxInternalProps { * since it follows a deliberate click. */ focusEndKey?: string | number; + /** + * Set by PromptBox when the shell's interim surface held the user's focus + * at handoff time. Focus the editor at the end as soon as it exists, on any + * pointer type: the user's tap already opened the soft keyboard, so this is + * a focus transfer from an already-focused input, not a keyboard summon. + */ + takeFocusOnCreate?: boolean; } interface DismissedTriggerRange { @@ -509,27 +454,6 @@ interface PromptActionInsertionRange { to: number; } -interface PromptActionCommand { - serializedText: string; - trailingText: string; - trigger: PromptMentionCommandTrigger; - suggestion: ProviderCommandSuggestion; -} - -const PROMPTBOX_INTERACTIVE_TARGET_SELECTOR = [ - "a[href]", - "button", - "input", - "select", - "textarea", - "[contenteditable='true']", - "[data-prompt-mention='true']", - "[role='button']", - "[role='link']", - "[role='menuitem']", - "[role='option']", -].join(","); - /** * Structural equality between the last value synced into the editor and the * incoming controlled value. This used to be a JSON.stringify key compare, @@ -905,12 +829,6 @@ function revealPromptEditorSelection({ } } -function isPromptBoxChromeTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - - return target.closest(PROMPTBOX_INTERACTIVE_TARGET_SELECTOR) === null; -} - function promptActionTextImmediatelyBeforeCursor( editor: Editor, actionText: string, @@ -928,13 +846,6 @@ function promptActionTextImmediatelyBeforeCursor( return before.endsWith(actionText); } -function promptActionCommandSerializedText(action: PromptBoxAction): string { - if (!action.command) { - return action.text; - } - return `${action.command.trigger}${action.command.name}`; -} - function isPromptActionCommandMention( node: ProseMirrorNode, actions: readonly PromptBoxAction[], @@ -1084,30 +995,6 @@ function getPromptActionInsertionRange({ return { from: selection.from, to: selection.to }; } -function promptActionCommandFromAction( - action: PromptBoxAction, -): PromptActionCommand | null { - if (action.kind === "skills" || !action.command) { - return null; - } - - const { trigger, name, trailingText } = action.command; - const serializedText = `${trigger}${name}`; - return { - serializedText, - trailingText, - trigger, - suggestion: { - kind: "command", - name, - source: "command", - origin: "user", - description: null, - argumentHint: null, - }, - }; -} - function promptActionTriggers( triggers: readonly TypeaheadTrigger[], commandAction: PromptActionCommand | null, @@ -1193,7 +1080,7 @@ export function PromptBoxInternal({ onSubmit, onEscape, blurOnPointerSubmit = false, - placeholder = "Ask anything. @ to mention files, folders, or sections", + placeholder = DEFAULT_PROMPTBOX_PLACEHOLDER, autoFocus = true, className, textEffects, @@ -1216,6 +1103,7 @@ export function PromptBoxInternal({ voice, promptBoxRef, focusEndKey, + takeFocusOnCreate = false, }: PromptBoxInternalProps) { const focusComposerShortcut = useAppCommandShortcut("composer.focus"); const { @@ -1989,6 +1877,19 @@ export function PromptBoxInternal({ shouldAvoidSoftKeyboardAutofocus, ]); + // Handoff focus transfer (see the `takeFocusOnCreate` prop doc). Runs once + // per mount even if the editor is later rebuilt (rich-text toggle), and on + // coarse pointers too — the shell's interim surface already opened the soft + // keyboard, so skipping here would close it. + const tookHandoffFocusRef = useRef(false); + useEffect(() => { + if (!takeFocusOnCreate || tookHandoffFocusRef.current) return; + if (!editor || editor.isDestroyed) return; + tookHandoffFocusRef.current = true; + focusEditorAtEnd(editor); + scheduleRevealEditorSelection(); + }, [editor, scheduleRevealEditorSelection, takeFocusOnCreate]); + useEffect(() => { mentionRangesRef.current = mentionRanges; }, [mentionRanges]); @@ -2523,26 +2424,13 @@ export function PromptBoxInternal({ const currentEditor = editorRef.current; if (!currentEditor || currentEditor.isDestroyed) { - const currentValue = valueRef.current; - if (currentValue.endsWith(action.text)) return; - if (commandAction) { - const start = currentValue.length; - const nextValue = `${currentValue}${commandAction.serializedText}${commandAction.trailingText}`; - onChangeRef.current(nextValue, [ - ...mentionRangesRef.current, - { - start, - end: start + commandAction.serializedText.length, - resource: promptCommandResourceFromSuggestion({ - suggestion: commandAction.suggestion, - trigger: commandAction.trigger, - }), - }, - ]); - } else { - onChangeRef.current(`${currentValue}${action.text}`, [ - ...mentionRangesRef.current, - ]); + const appended = appendPromptActionToDraft({ + action, + text: valueRef.current, + mentions: mentionRangesRef.current, + }); + if (appended !== null) { + onChangeRef.current(appended.text, appended.mentions); } return; } diff --git a/apps/app/src/components/promptbox/PromptBoxShell.stories.tsx b/apps/app/src/components/promptbox/PromptBoxShell.stories.tsx new file mode 100644 index 0000000000..1f55146d2e --- /dev/null +++ b/apps/app/src/components/promptbox/PromptBoxShell.stories.tsx @@ -0,0 +1,178 @@ +import { useMemo } from "react"; +import type { PromptTextMention } from "@bb/domain"; +import { ExecutionControls } from "@/components/promptbox/ExecutionControls"; +import { PromptBoxInternal } from "@/components/promptbox/PromptBoxInternal"; +import { PromptBoxShell } from "@/components/promptbox/PromptBoxShell"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { + makeAttachmentsConfig, + makeExecutionControlsProps, + makeTypeaheadConfig, +} from "../../../.ladle/story-fixtures"; + +export default { + title: "promptbox/Prompt Box Shell", +}; + +const noop = () => {}; + +const SAVED_DRAFT = "Refactor the settings page to use the new form primitives"; +const SAVED_DRAFT_WITH_MENTION = "Look at @src/components/settings.tsx first"; +const SAVED_DRAFT_MENTIONS: readonly PromptTextMention[] = [ + { + start: 8, + end: 36, + resource: { + kind: "path", + source: "workspace", + entryKind: "file", + path: "src/components/settings.tsx", + label: "settings.tsx", + }, + }, +]; + +function useSharedProps() { + const typeahead = useMemo(() => makeTypeaheadConfig(), []); + const attachments = useMemo(() => makeAttachmentsConfig(), []); + const execution = useMemo(() => makeExecutionControlsProps(), []); + return { typeahead, attachments, execution }; +} + +/** + * The shell must be indistinguishable from the real composer at rest: same + * frame, placeholder, action row, and send button. Each row pairs the shell + * (top) with the mounted editor (bottom, autoFocus off so it stays closed). + */ +export function ShellVersusMountedEditor() { + const { typeahead, attachments, execution } = useSharedProps(); + const footerStart = ; + + return ( + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ ); +} + +/** One-line mobile presentation parity: 48px row, actions pinned right. */ +export function CompactShellVersusMountedEditor() { + const { typeahead, attachments } = useSharedProps(); + + return ( + + +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/app/src/components/promptbox/PromptBoxShell.tsx b/apps/app/src/components/promptbox/PromptBoxShell.tsx new file mode 100644 index 0000000000..9385868d09 --- /dev/null +++ b/apps/app/src/components/promptbox/PromptBoxShell.tsx @@ -0,0 +1,773 @@ +import { + useEffect, + useLayoutEffect, + useRef, + type DragEvent as ReactDragEvent, + type ChangeEvent, + type FormEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import type { PromptTextMention } from "@bb/domain"; +import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; +import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bb/shared-ui/tooltip"; +import { + COARSE_POINTER_PROMPT_ACTION_BUTTON_CLASS, + COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS, + 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 { ComposerActionsSlot } from "@/components/plugin/PluginComposerActions"; +import { + PluginComposerViewProvider, + useOptionalPluginComposerView, + usePluginComposerHost, + usePluginComposerViewModel, +} from "@/components/plugin/plugin-composer-host"; +import type { ComposerView } from "@get-bb/plugin-sdk"; +import { AttachmentPreview } from "./AttachmentPreview"; +import { VoiceRecordingBar } from "./VoiceRecordingBar"; +import { + ComposerPlusMenuSlot, + type PromptBoxAction, +} from "./PromptBoxActionsMenu"; +import { PROMPT_MENTION_PILL_CLASS } from "./mentions/prompt-mention-display"; +import { PromptMentionIcon } from "./mentions/PromptMentionIcon"; +import type { + AttachmentsConfig, + PromptBoxSubmissionConfig, + PromptVoiceConfig, +} from "./PromptBoxInternal"; + +/** + * The composer's closed-state chrome with zero tiptap imports. + * + * PromptBox renders this shell until the user's first focus/tap/paste/drop + * (or a programmatic focus request) hands off to the lazily loaded + * PromptBoxInternal editor. The shell must look exactly like the mounted + * composer at rest, so its frame, editor region, and action row reuse the + * internal editor's class strings and data attributes — including a + * `.ProseMirror`-shaped `contenteditable="false"` preview surface so the + * placeholder and compact-truncation CSS in app.css applies unchanged. + * + * Dependency direction is one-way on purpose: PromptBoxInternal imports + * shared chrome from this module, never the reverse (type-only imports + * excepted — they are erased at build time). Future composer features land + * in the internal module, not here. + */ + +export const PROMPTBOX_MIN_HEIGHT = 68; +export const DEFAULT_PROMPTBOX_PLACEHOLDER = + "Ask anything. @ to mention files, folders, or sections"; +export const COMPACT_PROMPT_ACTION_BUTTON_CLASS = + "size-8 p-0 transition-all [&_svg]:size-4"; +export const COLLAPSING_GRID_CLASS = + "grid transition-[grid-template-rows] duration-[180ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"; + +export type PromptBoxEditorLayout = "thread" | "root-compose"; + +export interface PromptBoxCompactConfig { + isCompact: boolean; + placeholder?: string; +} + +// Reserve the fixed action row and border so the standard prompt box does +// not grow beyond its intended viewport-relative cap. Shared with +// ComposerEditorSlot so the shell and the mounted editor cap identically. +export const COMPOSER_EDITOR_MAX_HEIGHT_BY_LAYOUT: Record< + PromptBoxEditorLayout, + string +> = { + thread: "calc(50dvh - 3rem)", + "root-compose": "calc(70dvh - 3rem)", +}; + +export const PROMPTBOX_INTERACTIVE_TARGET_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "[contenteditable='true']", + "[data-prompt-mention='true']", + "[role='button']", + "[role='link']", + "[role='menuitem']", + "[role='option']", +].join(","); + +export function isPromptBoxChromeTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + + return target.closest(PROMPTBOX_INTERACTIVE_TARGET_SELECTOR) === null; +} + +export interface PromptSubmitButtonProps { + canSubmit: boolean; + className: string; + disabledReason: string | undefined; + isCompact: boolean; + isSubmitting: boolean; + onClick: (event: ReactMouseEvent) => void; + onPointerDown: (event: ReactPointerEvent) => void; + title: string; +} + +export function PromptSubmitButton({ + canSubmit, + className, + disabledReason, + isCompact, + isSubmitting, + onClick, + onPointerDown, + title, +}: PromptSubmitButtonProps) { + const button = ( + + ); + + if (!disabledReason) return button; + + return ( + + + + + {button} + + + {disabledReason} + + + ); +} + +interface PromptBoxShellPreviewSegment { + key: string; + mention: PromptTextMention | null; + text: string; +} + +function promptBoxShellPreviewSegments( + value: string, + mentionRanges: readonly PromptTextMention[], +): PromptBoxShellPreviewSegment[] { + const sortedMentions = [...mentionRanges].sort( + (left, right) => left.start - right.start || left.end - right.end, + ); + const segments: PromptBoxShellPreviewSegment[] = []; + let cursor = 0; + for (const mention of sortedMentions) { + if (mention.start < cursor || mention.end > value.length) continue; + if (mention.start > cursor) { + segments.push({ + key: `text-${cursor}`, + mention: null, + text: value.slice(cursor, mention.start), + }); + } + segments.push({ + key: `mention-${mention.start}`, + mention, + text: value.slice(mention.start, mention.end), + }); + cursor = mention.end; + } + if (cursor < value.length) { + segments.push({ + key: `text-${cursor}`, + mention: null, + text: value.slice(cursor), + }); + } + return segments; +} + +export interface PromptBoxShellProps { + id?: string; + value: string; + mentionRanges: readonly PromptTextMention[]; + /** Raw placeholder; the shell applies the compact override like the editor. */ + placeholder: string; + className?: string; + header?: ReactNode; + footerStart?: ReactNode; + submission?: PromptBoxSubmissionConfig; + minHeight?: number; + attachments?: AttachmentsConfig; + promptActions?: readonly PromptBoxAction[]; + suppressPluginComposerCustomizations?: boolean; + editorLayout?: PromptBoxEditorLayout; + onCollapse?: () => void; + compact?: PromptBoxCompactConfig; + containerCompactPlaceholder?: string; + voice?: PromptVoiceConfig; + onComposerLayoutChange?: (layout: ComposerView["layout"]) => void; + /** + * The buffered-input surface PromptBox overlays on the editor region while + * the editor chunk loads: an invisible textarea that captures the tap, + * opens the soft keyboard, and streams keystrokes into the draft. + */ + interimSurface?: ReactNode; + /** True while the interim surface is focused: paints the caret affordance. */ + showInterimCaret?: boolean; + /** + * The interim surface renders its own text natively (empty-draft handoff), + * so the preview must not echo the draft underneath it. + */ + suppressPreviewText?: boolean; + /** Chrome mousedown outside interactive targets — mirror of the editor's focus-on-chrome-click. */ + onChromeMouseDown?: (event: ReactMouseEvent) => void; + /** Submit from the form or the submit button (Enter in the interim surface routes here too). */ + onSubmitIntent: () => void; + /** A prompt action picked from the plus menu before the editor exists. */ + onPromptAction: (action: PromptBoxAction) => void; + /** Warm the editor chunk (pointerenter/focus). */ + onPreload?: () => void; + /** Compose intent that must realize the editor without stealing focus (drop, voice, action). */ + onComposeIntent?: () => void; +} + +export function PromptBoxShell({ + id, + value, + mentionRanges, + placeholder, + className, + header, + footerStart, + submission = {}, + minHeight = PROMPTBOX_MIN_HEIGHT, + attachments: attachmentConfig = {}, + promptActions, + suppressPluginComposerCustomizations = false, + editorLayout = "thread", + onCollapse, + compact, + containerCompactPlaceholder, + voice, + onComposerLayoutChange, + interimSurface, + showInterimCaret = false, + suppressPreviewText = false, + onChromeMouseDown, + onSubmitIntent, + onPromptAction, + onPreload, + onComposeIntent, +}: PromptBoxShellProps) { + const focusComposerShortcut = useAppCommandShortcut("composer.focus"); + const { + isSubmitting = false, + disabled: submitDisabled = false, + disabledReason: submitDisabledReason, + title: submitTitle = "Submit (Enter)", + isRunning = false, + onStop, + } = submission; + const { + items: attachments = [], + isAttaching = false, + error: attachmentError = null, + onAttachFiles, + onRemove: onRemoveAttachment, + projectId: attachmentProjectId, + } = attachmentConfig; + const isPointerCoarse = usePointerCoarse(); + const formRef = useRef(null); + const attachmentInputRef = useRef(null); + + const isVoiceRecording = voice?.state === "recording"; + const isVoiceProcessing = voice?.state === "transcribing"; + const showVoiceActionGroup = isVoiceRecording || isVoiceProcessing; + const isVoiceBusy = showVoiceActionGroup; + const showCompactLayout = compact?.isCompact === true && !showVoiceActionGroup; + const effectivePlaceholder = showCompactLayout + ? (compact.placeholder ?? placeholder) + : placeholder; + + useLayoutEffect(() => { + const formElement = formRef.current; + if (!formElement) return; + if (containerCompactPlaceholder === undefined) { + formElement.style.removeProperty( + "--promptbox-container-compact-placeholder", + ); + return; + } + formElement.style.setProperty( + "--promptbox-container-compact-placeholder", + JSON.stringify(containerCompactPlaceholder), + ); + }, [containerCompactPlaceholder]); + + const pluginComposerHost = usePluginComposerHost(); + const composerLayout: ComposerView["layout"] = showCompactLayout + ? "compact" + : "expanded"; + const localComposerView = usePluginComposerViewModel({ + scope: pluginComposerHost?.scope ?? { + kind: "new-thread", + projectId: null, + }, + layout: composerLayout, + text: value, + attachmentCount: attachments.length, + isRunning, + isSubmitting, + }); + const composerView = useOptionalPluginComposerView() ?? localComposerView; + useEffect(() => { + onComposerLayoutChange?.(composerLayout); + }, [composerLayout, onComposerLayoutChange]); + + const trimmedValue = value.trim(); + const hasAttachments = attachments.length > 0; + const hasSubmittableInput = trimmedValue.length > 0 || hasAttachments; + const canSubmit = + hasSubmittableInput && !isSubmitting && !submitDisabled && !isVoiceBusy; + const showStop = Boolean(isRunning && onStop && !canSubmit && !isVoiceBusy); + const canStartVoiceInput = + voice !== undefined && voice.isSupported && !isSubmitting; + const showVoiceAsPrimaryAction = + isPointerCoarse && !hasSubmittableInput && canStartVoiceInput; + const effectiveSubmitTitle = + !canSubmit && submitDisabledReason ? submitDisabledReason : submitTitle; + + const handleVoicePointerDown = ( + event: ReactPointerEvent, + ) => { + if (!isPointerCoarse || event.button !== 0) return; + // Keep mobile voice activation from focusing the button and expanding + // the follow-up composer before click can start recording. + event.preventDefault(); + }; + const startVoiceInput = () => { + onComposeIntent?.(); + void voice?.start(); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + onSubmitIntent(); + }; + + const handleAttachmentInputChange = ( + event: ChangeEvent, + ) => { + const fileList = event.target.files; + if (!onAttachFiles || !fileList || fileList.length === 0) return; + void onAttachFiles(Array.from(fileList)); + event.target.value = ""; + }; + + const handleDrop = (event: ReactDragEvent) => { + if (!onAttachFiles) return; + event.preventDefault(); + if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) + return; + void onAttachFiles(Array.from(event.dataTransfer.files)); + onComposeIntent?.(); + }; + + const isPreviewEmpty = value.length === 0; + const previewSegments = + isPreviewEmpty || suppressPreviewText + ? [] + : promptBoxShellPreviewSegments(value, mentionRanges); + + return ( +
{ + if (!onAttachFiles) return; + event.preventDefault(); + }} + onDrop={handleDrop} + className={cn( + "group/promptbox relative w-full rounded-xl border border-border bg-background shadow-lift", + showCompactLayout && "overflow-hidden", + className, + )} + > + +
+
+ {header && !showCompactLayout ? ( +
+ {header} +
+ ) : null} +
+ {!showCompactLayout ? ( + <> +
+ +
+ {onCollapse ? ( +
+ +
+ ) : null} + + ) : null} +
+
+ {/* Non-editable stand-in for the ProseMirror root. It keeps the + editor's classes and [contenteditable] attribute shape so the + placeholder and compact-preview CSS in app.css style it + exactly like the resting editor. */} +
+ {isPreviewEmpty && !suppressPreviewText ? ( +

+
+

+ ) : ( +

+ {previewSegments.map((segment) => + segment.mention ? ( + + + + {segment.mention.resource.label} + + + ) : ( + {segment.text} + ), + )} + {showInterimCaret ? ( + + ) : null} +

+ )} +
+
+
+ {interimSurface} +
+ + {!showCompactLayout ? ( +
+ {}} + onRemoveAttachment={onRemoveAttachment} + /> + + {attachmentError ? ( +
+ {attachmentError} +
+ ) : null} +
+ ) : null} + + +
+ {voice && showVoiceActionGroup ? ( +
+ +
+ ) : null} + {!showCompactLayout ? ( +
+ attachmentInputRef.current?.click() + : undefined + } + onAction={onPromptAction} + includePluginContributions={ + !suppressPluginComposerCustomizations + } + /> + {footerStart} +
+ ) : null} +
+ + {!showCompactLayout ? ( + <> + {voice && + !showVoiceActionGroup && + (!showVoiceAsPrimaryAction || showStop) ? ( + + ) : null} + + ) : null} +
+ {showStop ? ( + + ) : showVoiceAsPrimaryAction ? ( + + ) : ( + {}} + onClick={() => {}} + title={effectiveSubmitTitle} + /> + )} +
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/app/src/components/promptbox/prompt-action-draft.ts b/apps/app/src/components/promptbox/prompt-action-draft.ts new file mode 100644 index 0000000000..d89f78573c --- /dev/null +++ b/apps/app/src/components/promptbox/prompt-action-draft.ts @@ -0,0 +1,102 @@ +import type { PromptMentionCommandTrigger, PromptTextMention } from "@bb/domain"; +import type { ProviderCommandSuggestion } from "@bb/client-core"; +import { promptCommandResourceFromSuggestion } from "./editor/prompt-editor-serialization"; +import type { PromptBoxAction } from "./PromptBoxActionsMenu"; + +/** + * Prompt-action → draft translation shared by the mounted editor's + * editorless fallback (PromptBoxInternal) and the pre-handoff shell + * (PromptBox). Deliberately tiptap-free: prompt-editor-serialization's + * tiptap imports are type-only, so this module stays in the light route + * closure while the editor chunk loads on demand. + */ + +export interface PromptActionCommand { + serializedText: string; + trailingText: string; + trigger: PromptMentionCommandTrigger; + suggestion: ProviderCommandSuggestion; +} + +export function promptActionCommandSerializedText( + action: PromptBoxAction, +): string { + if (!action.command) { + return action.text; + } + return `${action.command.trigger}${action.command.name}`; +} + +export function promptActionCommandFromAction( + action: PromptBoxAction, +): PromptActionCommand | null { + if (action.kind === "skills" || !action.command) { + return null; + } + + const { trigger, name, trailingText } = action.command; + const serializedText = `${trigger}${name}`; + return { + serializedText, + trailingText, + trigger, + suggestion: { + kind: "command", + name, + source: "command", + origin: "user", + description: null, + argumentHint: null, + }, + }; +} + +export interface AppendPromptActionToDraftArgs { + action: PromptBoxAction; + text: string; + mentions: readonly PromptTextMention[]; +} + +export interface AppendedPromptActionDraft { + text: string; + mentions: PromptTextMention[]; +} + +/** + * Applies a prompt action to a draft without an editor: append the action's + * text (or its command pill serialization plus mention range) at the end. + * Returns null when the action is a no-op — empty text, or the draft already + * ends with it. + */ +export function appendPromptActionToDraft({ + action, + text, + mentions, +}: AppendPromptActionToDraftArgs): AppendedPromptActionDraft | null { + if (action.text.length === 0) return null; + if (text.endsWith(action.text)) return null; + + const commandAction = promptActionCommandFromAction(action); + if (commandAction) { + const start = text.length; + return { + text: `${text}${commandAction.serializedText}${commandAction.trailingText}`, + mentions: [ + ...mentions, + { + start, + end: start + commandAction.serializedText.length, + resource: promptCommandResourceFromSuggestion({ + suggestion: commandAction.suggestion, + trigger: commandAction.trigger, + }), + }, + ], + }; + } + + return { + text: `${text}${action.text}`, + mentions: [...mentions], + }; +} From a82697777a1e8d21a6f050636ef07ab5a48c6b35 Mon Sep 17 00:00:00 2001 From: Vedran Burojevic Date: Mon, 24 Aug 2026 17:41:18 +0200 Subject: [PATCH 2/3] Lazy-load the tiptap composer behind a first-focus handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of plan 008. PromptBox renders the shell until the first compose intent — a tap/focus on the text region, a paste or drop, a plus-menu action, or a programmatic focus request (promptBoxRef.focusEnd(), a focusEndKey change, desktop autoFocus) — then dynamically imports PromptBoxInternal and mounts it for good. While the chunk loads, an invisible interim textarea overlaid on the text region owns the focus: the tap focuses it natively (so the soft keyboard opens inside the user's gesture) and every keystroke and paste flushes straight into the controlled draft, which the shell preview echoes. Empty drafts render the interim text natively (native caret and IME preview); continuing drafts stay invisible behind the preview echo plus a caret affordance. The swap defers during IME composition so no composition is cut; at swap the editor mounts with the full draft and takes over focus (takeFocusOnCreate) with the caret at the end. The chunk also prefetches at idle and on pointerenter/focus so the handoff is normally mount-only. FollowUpPromptBox and NewThreadPromptBox mount PromptBox instead of PromptBoxInternal; the FollowUpPromptBox suite mocks the new boundary. New handoff tests fail against the eager architecture (7/8; the autoFocus parity test is trivially green there) and pass after. Co-Authored-By: Claude Fable 5 --- .../promptbox/FollowUpPromptBox.test.tsx | 4 +- .../promptbox/FollowUpPromptBox.tsx | 10 +- .../promptbox/NewThreadPromptBox.tsx | 8 +- .../components/promptbox/PromptBox.test.tsx | 223 +++++++ .../src/components/promptbox/PromptBox.tsx | 566 ++++++++++++++++++ 5 files changed, 799 insertions(+), 12 deletions(-) create mode 100644 apps/app/src/components/promptbox/PromptBox.test.tsx create mode 100644 apps/app/src/components/promptbox/PromptBox.tsx 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 `