From 2864befc2f55c4e4b19aa43979ad5df1da51015b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 8 Aug 2026 17:25:22 +0000 Subject: [PATCH] Lazy-load workspace route editor code --- apps/app/bundle-budget.json | 29 +- apps/app/scripts/check-bundle-budget.mjs | 114 +- .../src/components/git-diff/GitDiffCard.tsx | 8 +- .../components/git-diff/GitDiffCardBody.tsx | 21 +- .../components/git-diff/PierreDiffView.tsx | 23 + .../components/git-diff/git-diff-options.ts | 7 + .../promptbox/PromptBoxAppShortcuts.test.tsx | 2 +- .../PromptBoxInternal.ipados.test.tsx | 2 +- .../promptbox/PromptBoxInternal.test.tsx | 2 +- .../promptbox/PromptBoxInternal.tsx | 3321 +---------------- .../promptbox/PromptBoxInternalImpl.tsx | 3270 ++++++++++++++++ .../promptbox/prompt-box-runtime.ts | 21 + .../secondary-panel/FilePreview.test.tsx | 6 +- .../secondary-panel/FilePreview.tsx | 1375 +------ .../secondary-panel/FilePreviewImpl.tsx | 1350 +++++++ .../secondary-panel/ThreadSecondaryPanel.tsx | 15 +- .../ThreadSecondaryPanelTabContent.tsx | 45 +- .../threadSecondaryPanelLayout.ts | 4 + .../timeline/LazyThreadTimelineRows.tsx | 23 + .../thread/timeline/ThreadTimelineSurface.tsx | 2 +- .../src/components/thread/timeline/index.ts | 2 +- apps/app/src/views/PluginPanelView.tsx | 26 +- apps/app/src/views/RootComposeView.tsx | 19 +- .../SplitThreadArea.stories.test.tsx | 6 +- .../SplitWorkspaceSecondaryPanelHost.tsx | 2 +- .../ThreadDetailWorkerPoolProvider.tsx | 26 +- .../ThreadDetailWorkerPoolProviderImpl.tsx | 30 + apps/app/vite-bundle-stats.ts | 103 +- 28 files changed, 5077 insertions(+), 4777 deletions(-) create mode 100644 apps/app/src/components/git-diff/PierreDiffView.tsx create mode 100644 apps/app/src/components/git-diff/git-diff-options.ts create mode 100644 apps/app/src/components/promptbox/PromptBoxInternalImpl.tsx create mode 100644 apps/app/src/components/promptbox/prompt-box-runtime.ts create mode 100644 apps/app/src/components/secondary-panel/FilePreviewImpl.tsx create mode 100644 apps/app/src/components/secondary-panel/threadSecondaryPanelLayout.ts create mode 100644 apps/app/src/components/thread/timeline/LazyThreadTimelineRows.tsx create mode 100644 apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProviderImpl.tsx diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 5f16623f40..7d1bf54b1b 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -14,10 +14,9 @@ "them when a change wins headroom. Raising one is a deliberate decision", "that needs a reason in the pull request, not a routine edit.", "", - "There is deliberately no per-chunk limit. Parse and compile cost scales", - "with the total bytes on the boot path, not with how they are divided, so", - "splitting one boot chunk into two would satisfy a per-chunk cap while", - "making the page slightly slower for the extra request.", + "The workspace checkout display chunk has a separate limit because it was", + "the shared owner of the prompt, Markdown, and diff renderers. The route", + "package check keeps those features behind their dynamic boundaries.", "", "forbiddenBootPackages is the more important half. These packages are all", "large and all needed only after a user action (open a diff, focus the", @@ -27,6 +26,7 @@ ], "maxBootBytes": 1707047, "maxBootBrotliBytes": 445742, + "maxWorkspaceCheckoutDisplayChunkBytes": 512000, "forbiddenBootPackages": [ "@pierre/diffs", "@pierre/theming", @@ -48,5 +48,26 @@ "prosemirror-view", "rehype-katex", "shiki" + ], + "forbiddenWorkspaceRoutePackages": [ + "@pierre/diffs", + "@pierre/theming", + "@shikijs/core", + "@shikijs/engine-javascript", + "@shikijs/engine-oniguruma", + "@shikijs/langs", + "@shikijs/vscode-textmate", + "@tiptap/core", + "@tiptap/pm", + "@tiptap/react", + "@tiptap/starter-kit", + "cytoscape", + "katex", + "oniguruma-to-es", + "prosemirror-model", + "prosemirror-state", + "prosemirror-view", + "rehype-katex", + "shiki" ] } diff --git a/apps/app/scripts/check-bundle-budget.mjs b/apps/app/scripts/check-bundle-budget.mjs index 4db231feb4..4f2fa076bb 100644 --- a/apps/app/scripts/check-bundle-budget.mjs +++ b/apps/app/scripts/check-bundle-budget.mjs @@ -32,54 +32,104 @@ const budget = JSON.parse(fs.readFileSync(budgetPath, "utf8")); const kb = (n) => `${(n / 1024).toFixed(1)} KB`; const failures = []; +const minPrecompressBytes = 1024; -// A boot chunk with no .br file would otherwise weigh zero against the -// compressed budget, so an unrun precompression step could hide real growth. -// Treat it as an error rather than guessing a size. -const missingBrotli = []; -let bootBytes = 0; -let bootBrotliBytes = 0; -for (const chunk of stats.bootChunks) { - bootBytes += chunk.bytes; - const brotliPath = path.join(distDir, `${chunk.fileName}.br`); - if (fs.existsSync(brotliPath)) { - bootBrotliBytes += fs.statSync(brotliPath).size; - } else { - missingBrotli.push(chunk.fileName); +// A chunk with no .br file would otherwise weigh zero against the compressed +// budget. The precompressor deliberately leaves files below 1 KB untouched, +// so count their full size and fail only when a larger compressed file is gone. +const missingBrotli = new Set(); +const measureChunks = (chunks) => { + let bytes = 0; + let brotliBytes = 0; + for (const chunk of chunks) { + bytes += chunk.bytes; + const brotliPath = path.join(distDir, `${chunk.fileName}.br`); + if (fs.existsSync(brotliPath)) { + brotliBytes += fs.statSync(brotliPath).size; + } else if (chunk.bytes < minPrecompressBytes) { + brotliBytes += chunk.bytes; + } else { + missingBrotli.add(chunk.fileName); + } } -} + return { bytes, brotliBytes }; +}; -const forbidden = new Set(budget.forbiddenBootPackages); -const offenders = new Map(); -for (const chunk of stats.bootChunks) { - for (const pkg of chunk.packages) { - if (!forbidden.has(pkg)) continue; - if (!offenders.has(pkg)) offenders.set(pkg, []); - offenders.get(pkg).push(chunk.fileName); +const bootPayload = measureChunks(stats.bootChunks); +const workspaceRoutePayload = measureChunks(stats.workspaceRouteChunks); + +const findForbiddenPackages = (chunks, forbiddenPackages) => { + const forbidden = new Set(forbiddenPackages); + const offenders = new Map(); + for (const chunk of chunks) { + for (const pkg of chunk.packages) { + if (!forbidden.has(pkg)) continue; + if (!offenders.has(pkg)) offenders.set(pkg, []); + offenders.get(pkg).push(chunk.fileName); + } } -} + return offenders; +}; + +const bootOffenders = findForbiddenPackages( + stats.bootChunks, + budget.forbiddenBootPackages, +); +const workspaceRouteOffenders = findForbiddenPackages( + stats.workspaceRouteChunks, + budget.forbiddenWorkspaceRoutePackages, +); -console.log(`boot payload: ${kb(bootBytes)} raw / ${kb(bootBrotliBytes)} brotli`); -console.log(` budget: ${kb(budget.maxBootBytes)} raw / ${kb(budget.maxBootBrotliBytes)} brotli`); +console.log( + `boot payload: ${kb(bootPayload.bytes)} raw / ${kb(bootPayload.brotliBytes)} brotli`, +); +console.log( + ` budget: ${kb(budget.maxBootBytes)} raw / ${kb(budget.maxBootBrotliBytes)} brotli`, +); console.log(` chunks: ${stats.bootChunks.length}`); +console.log( + `workspace route: ${kb(workspaceRoutePayload.bytes)} raw / ${kb(workspaceRoutePayload.brotliBytes)} brotli`, +); +console.log(` chunks: ${stats.workspaceRouteChunks.length}`); +console.log( + `checkout chunk: ${kb(stats.workspaceCheckoutDisplayChunk.bytes)} raw`, +); +console.log( + ` budget: ${kb(budget.maxWorkspaceCheckoutDisplayChunkBytes)} raw`, +); -if (missingBrotli.length > 0) { +if (missingBrotli.size > 0) { + failures.push( + `${missingBrotli.size} measured chunk(s) have no .br file, so the compressed total is understated: ${[...missingBrotli].join(", ")}. Run scripts/precompress-app-dist.mjs.`, + ); +} +if (bootPayload.bytes > budget.maxBootBytes) { failures.push( - `${missingBrotli.length} boot chunk(s) have no .br file, so the compressed total is understated: ${missingBrotli.join(", ")}. Run scripts/precompress-app-dist.mjs.`, + `boot payload is ${kb(bootPayload.bytes)}, over the ${kb(budget.maxBootBytes)} raw budget by ${kb(bootPayload.bytes - budget.maxBootBytes)}.`, ); } -if (bootBytes > budget.maxBootBytes) { +if (bootPayload.brotliBytes > budget.maxBootBrotliBytes) { failures.push( - `boot payload is ${kb(bootBytes)}, over the ${kb(budget.maxBootBytes)} raw budget by ${kb(bootBytes - budget.maxBootBytes)}.`, + `boot payload is ${kb(bootPayload.brotliBytes)} brotli, over the ${kb(budget.maxBootBrotliBytes)} budget by ${kb(bootPayload.brotliBytes - budget.maxBootBrotliBytes)}.`, ); } -if (bootBrotliBytes > budget.maxBootBrotliBytes) { +if ( + stats.workspaceCheckoutDisplayChunk.bytes > + budget.maxWorkspaceCheckoutDisplayChunkBytes +) { failures.push( - `boot payload is ${kb(bootBrotliBytes)} brotli, over the ${kb(budget.maxBootBrotliBytes)} budget by ${kb(bootBrotliBytes - budget.maxBootBrotliBytes)}.`, + `workspace checkout display chunk is ${kb(stats.workspaceCheckoutDisplayChunk.bytes)}, over the ${kb(budget.maxWorkspaceCheckoutDisplayChunkBytes)} raw budget.`, ); } -for (const [pkg, chunks] of offenders) { - failures.push(`${pkg} is in the boot payload (${chunks.join(", ")}). It must load on demand.`); +for (const [pkg, chunks] of bootOffenders) { + failures.push( + `${pkg} is in the boot payload (${chunks.join(", ")}). It must load on demand.`, + ); +} +for (const [pkg, chunks] of workspaceRouteOffenders) { + failures.push( + `${pkg} is in the workspace route preload (${chunks.join(", ")}). It must load on demand.`, + ); } if (failures.length > 0) { diff --git a/apps/app/src/components/git-diff/GitDiffCard.tsx b/apps/app/src/components/git-diff/GitDiffCard.tsx index a85d01af57..966718c9bd 100644 --- a/apps/app/src/components/git-diff/GitDiffCard.tsx +++ b/apps/app/src/components/git-diff/GitDiffCard.tsx @@ -28,13 +28,7 @@ export type { RequestDiffFileContents, } from "./GitDiffCardBody"; -export const GIT_DIFF_VIEW_BASE_OPTIONS = { - overflow: "scroll", - disableFileHeader: false, - // Reveal 30 unchanged lines per expand-up / expand-down click. Library - // default is 100 — too aggressive for our compact diff cards. - expansionLineCount: 30, -} as const; +export { GIT_DIFF_VIEW_BASE_OPTIONS } from "./git-diff-options"; export interface GitDiffCardProps { fileDiff: ParsedGitDiffFile; diff --git a/apps/app/src/components/git-diff/GitDiffCardBody.tsx b/apps/app/src/components/git-diff/GitDiffCardBody.tsx index 890e1719c3..b1f9179f80 100644 --- a/apps/app/src/components/git-diff/GitDiffCardBody.tsx +++ b/apps/app/src/components/git-diff/GitDiffCardBody.tsx @@ -1,6 +1,8 @@ import { type CSSProperties, type RefCallback, + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -13,7 +15,6 @@ import type { SelectedLineRange, SelectionSide, } from "@pierre/diffs"; -import { FileDiff as DiffView } from "@pierre/diffs/react"; import { useIntersectionObserver } from "usehooks-ts"; import { Button } from "@bb/shared-ui/button"; import { usePierreLineSelectionActions } from "./PierreLineSelectionActions.js"; @@ -33,6 +34,12 @@ import { type ParsedGitDiffFile, } from "./git-diff-parsing"; +const LazyPierreDiffView = lazy(() => + import("./PierreDiffView").then((module) => ({ + default: module.PierreDiffView, + })), +); + /** * One side of a diff file resolved for the card. `text` carries UTF-8 contents * for `@pierre/diffs` context expansion; `image` carries a data URL the card @@ -1174,11 +1181,13 @@ function GitDiffCardRawDiffBody({ onPointerUpCapture={lineSelectionActions.onPointerUpCapture} >
- + }> + +
{lineSelectionActions.menu} diff --git a/apps/app/src/components/git-diff/PierreDiffView.tsx b/apps/app/src/components/git-diff/PierreDiffView.tsx new file mode 100644 index 0000000000..938e02e972 --- /dev/null +++ b/apps/app/src/components/git-diff/PierreDiffView.tsx @@ -0,0 +1,23 @@ +import type { FileDiffOptions, SelectedLineRange } from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import type { ParsedGitDiffFile } from "./git-diff-parsing"; + +export interface PierreDiffViewProps { + fileDiff: ParsedGitDiffFile; + options: FileDiffOptions; + selectedLines: SelectedLineRange | null; +} + +export function PierreDiffView({ + fileDiff, + options, + selectedLines, +}: PierreDiffViewProps) { + return ( + + ); +} diff --git a/apps/app/src/components/git-diff/git-diff-options.ts b/apps/app/src/components/git-diff/git-diff-options.ts new file mode 100644 index 0000000000..e7b20a35d8 --- /dev/null +++ b/apps/app/src/components/git-diff/git-diff-options.ts @@ -0,0 +1,7 @@ +export const GIT_DIFF_VIEW_BASE_OPTIONS = { + overflow: "scroll", + disableFileHeader: false, + // Reveal 30 unchanged lines per expand-up or expand-down action. The library + // default of 100 lines is too large for compact diff cards. + expansionLineCount: 30, +} as const; diff --git a/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx b/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx index 559635889d..e350a9ca69 100644 --- a/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx @@ -12,7 +12,7 @@ import { import { INERT_TYPEAHEAD_COMMAND_CONFIG, PromptBoxInternal, -} from "./PromptBoxInternal"; +} from "./PromptBoxInternalImpl"; const testState = vi.hoisted(() => ({ calls: [] as string[], diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx index f50c35b933..2822748785 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx @@ -33,7 +33,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { INERT_TYPEAHEAD_COMMAND_CONFIG, PromptBoxInternal, -} from "./PromptBoxInternal"; +} from "./PromptBoxInternalImpl"; // ProseMirror waits this long before it replays a swallowed iOS Enter. const IOS_ENTER_REPLAY_MS = 200; diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 99b802a0a7..3cdf3fd927 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -57,7 +57,7 @@ import { type PromptBoxHandle, type PromptVoiceConfig, type TypeaheadConfig, -} from "./PromptBoxInternal"; +} from "./PromptBoxInternalImpl"; import type { PromptMentionSuggestion, ProviderCommandSuggestion, diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 8904f2b20a..e4adb10ade 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1,3282 +1,65 @@ -import { atom, useAtom } from "jotai"; -import { RESET, atomWithStorage } from "jotai/utils"; -import type { - PromptMentionCommandTrigger, - PromptTextMention, -} from "@bb/domain"; -import type { ComposerView } from "@bb/plugin-sdk"; -import type { Node as ProseMirrorNode, Slice } from "@tiptap/pm/model"; -import { TextSelection } from "@tiptap/pm/state"; -import { EditorContent, useEditor, type Editor } from "@tiptap/react"; -import { - useCallback, - useEffect, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - useState, - type ChangeEvent, - type FormEvent, - type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, - type ReactNode, - type Ref, -} from "react"; -import { - orderCommandSuggestionsBySection, - type ActiveTrigger, - type CommandMenuState, - type ComposerCommandSuggestion, - type MentionMenuState, - type ProviderCommandSuggestion, - type PromptMentionSuggestion, - type TypeaheadMenuState, - type TypeaheadTrigger, -} from "@/components/promptbox/mentions/types"; -import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; -import { - useAppCommandKeyDispatch, - useAppCommandShortcut, -} from "@/components/commands/AppCommandProvider"; -import { commandPillDismissedRangeEnd } from "@/components/promptbox/mentions/command-trigger"; -import { findActiveTrigger } from "@/components/promptbox/mentions/find-active-trigger"; -import { canLoadMoreCommandResults } from "@/components/promptbox/mentions/mention-menu-scroll"; -import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; -import { - PluginComposerActions, - usePluginComposerPlusMenuContributions, -} from "@/components/plugin/PluginComposerActions"; -import { - PluginComposerViewProvider, - useOptionalPluginComposerView, - usePluginComposerHost, - usePluginComposerViewModel, -} from "@/components/plugin/plugin-composer-host"; -import { composerCustomizationsForScope } from "@/components/plugin/composer-customizations"; -import { useComposerInputLock } from "@/lib/plugin-sdk-hooks"; -import { usePluginSlots } from "@/lib/plugin-slots"; -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 { blurActiveKeyboardInputWithin } from "@bb/shared-ui/overlay-trigger"; -import { createJsonLocalStorage } from "@/lib/browser-storage"; -import { - DEFAULT_PLUGIN_MENTION_TRIGGER, - type PluginMentionTrigger, -} from "@/lib/plugin-mention-triggers"; -import { useRichTextEditingPreference } from "@/lib/rich-text-editing-preference"; -import { - arePromptDraftStatesEqual, - isPromptDraftEmpty, - type PromptDraftAttachment, - type PromptDraftState, -} from "@/lib/prompt-draft"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { AttachmentPreview } from "./AttachmentPreview"; -import { VoiceRecordingBar } from "./VoiceRecordingBar"; -import { - PromptBoxActionsMenu, - type PromptBoxAction, -} from "./PromptBoxActionsMenu"; -import { - PromptMentionLinkContext, - type PromptMentionLinkResolver, -} from "./editor/prompt-mention-link"; -import { - refreshPromptDecorations, - type PromptDecorationSource, - type PromptDraftObserver, -} from "./editor/prompt-decoration-extension"; -import type { ComposerTextEffectSource } from "@/lib/composer-text-effects"; -import { promptEditorExtensions } from "./editor/prompt-editor-extensions"; -import { - promptCommandResourceFromSuggestion, - promptEditorClipboardTextFromSlice, - promptEditorContentFromValue, - promptEditorInlineContentFromValue, - promptEditorValueFromDoc, - promptEditorValueFromSlice, - parsePromptEditorMentionAttrs, - promptMentionResourceFromSuggestion, - type PromptEditorValue, -} from "./editor/prompt-editor-serialization"; -import { - exitTrailingBlockquoteBreak, - insertParagraphBeforeBlockquote, - removeEmptyBlockquotes, -} from "./editor/prompt-editor-blockquote"; -import { exitHeading } from "./editor/prompt-editor-heading"; -import { applyPromptListNewline } from "./editor/prompt-editor-list"; -import { applyPromptParagraphNewline } from "./editor/prompt-editor-paragraph"; -import { MentionMenu, type TypeaheadSuggestion } from "./mentions/MentionMenu"; -import { parsePromptMentionClipboardElement } from "./mentions/prompt-mention-clipboard"; +import { lazy, Suspense } from "react"; +import type { PromptBoxInternalProps } from "./PromptBoxInternalImpl"; -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", - "ASIDE", - "BLOCKQUOTE", - "DIV", - "DD", - "DL", - "DT", - "FIGCAPTION", - "FIGURE", - "FOOTER", - "FORM", - "H1", - "H2", - "H3", - "H4", - "H5", - "H6", - "HEADER", - "HR", - "MAIN", - "NAV", - "P", - "SECTION", - "TABLE", - "TBODY", - "TD", - "TFOOT", - "TH", - "THEAD", - "TR", -]); -const RICH_PASTE_IGNORED_TAGS = new Set([ - "HEAD", - "LINK", - "META", - "NOSCRIPT", - "SCRIPT", - "STYLE", - "TITLE", -]); +const LazyPromptBoxInternal = lazy(() => + import("./PromptBoxInternalImpl").then((module) => ({ + default: module.PromptBoxInternal, + })), +); -function hasWhitespaceAfterPosition( - doc: ProseMirrorNode, - position: number, -): boolean { - const nextNode = doc.resolve(position).nodeAfter; - if (!nextNode) { - return false; - } - if (nextNode.isText) { - return /^\s/u.test(nextNode.text ?? ""); - } - return nextNode.type.name === "hardBreak"; -} - -type ZenModeLayout = "thread" | "root-compose"; - -const ZEN_MODE_STORAGE_KEY: Record = { - thread: "bb.promptbox.zen-mode.thread", - "root-compose": "bb.promptbox.zen-mode.root-compose", -}; - -const ZEN_MODE_HEIGHT_CLASS: Record = { - thread: "h-[50dvh]", - "root-compose": "h-[70dvh]", -}; - -const PROMPTBOX_MAX_HEIGHT_BY_LAYOUT: Record = { - thread: "50dvh", - "root-compose": "70dvh", -}; - -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 interface PromptBoxSubmissionConfig { - isSubmitting?: boolean; - disabled?: boolean; - title?: string; - isRunning?: boolean; - onStop?: () => void; - onModifierSubmit?: () => void; -} - -/** - * The `@`-mention half of {@link TypeaheadConfig}. Unchanged from the prior - * `MentionsConfig` surface other than living under `typeahead.mention`. - */ -export interface TypeaheadMentionConfig { - /** Mention trigger characters to watch. Defaults to `@`. */ - triggers?: readonly PluginMentionTrigger[]; - suggestions: readonly PromptMentionSuggestion[]; - isLoading: boolean; - isError: boolean; - /** Called whenever the active mention query changes; null when no mention is active. */ - onQueryChange: ( - query: string | null, - trigger: PluginMentionTrigger | null, - ) => void; - /** - * Resolves the click action for an inserted mention pill (navigate to a - * thread, open a file preview). Omit to render pills as non-interactive - * text; returns null per-resource when that mention isn't openable here. - */ - resolveLink?: PromptMentionLinkResolver; -} - -/** - * The command-typeahead half of {@link TypeaheadConfig}. `trigger` is the - * provider's command char or `null` when the provider has no command - * surface — in which case the composer never activates a command trigger and - * the rest of this config is inert. - * - * Hosts wire `suggestions` / `isLoading` / `isError` from - * `useCommandSuggestions`; `onQueryChange` feeds that hook the text typed - * after the trigger (`null` when no command trigger is active). - */ -export interface TypeaheadCommandConfig { - trigger: PromptMentionCommandTrigger | null; - suggestions: readonly ComposerCommandSuggestion[]; - isLoading: boolean; - isError: boolean; - hasMore: boolean; - isLoadingMore: boolean; - loadMore: () => void; - /** Called whenever the active command query changes; null when no command trigger is active. */ - onQueryChange: (query: string | null) => void; -} - -/** - * Generalized composer typeahead config covering both trigger kinds. `@` - * mentions are always available; commands are active only when - * `command.trigger` is non-null. Hosts supply both halves; the composer picks - * the active trigger from the caret and renders the matching data source. - */ -export interface TypeaheadConfig { - mention: TypeaheadMentionConfig; - command: TypeaheadCommandConfig; -} - -/** - * Inert command half: no trigger, no suggestions, no-op query change. Hosts use - * it as `typeahead.command` until they wire real command data from - * `useCommandSuggestions`. With `trigger: null` the composer never activates a - * command trigger, so the rest of the fields are never read. - */ -export const INERT_TYPEAHEAD_COMMAND_CONFIG: TypeaheadCommandConfig = { - trigger: null, - suggestions: [], - isLoading: false, - isError: false, - hasMore: false, - isLoadingMore: false, - loadMore: () => {}, - onQueryChange: () => {}, -}; - -export interface AttachmentsConfig { - items?: PromptDraftAttachment[]; - isAttaching?: boolean; - error?: string | null; - onAttachFiles?: (files: File[]) => void | Promise; - onRemove?: (path: string) => void; - projectId?: string; -} - -export interface PromptBoxZenModeConfig { - layout?: ZenModeLayout; - storageKey?: string | null; - resetKey?: string | number; - resetOnSubmit?: boolean; -} - -export interface PromptBoxCompactConfig { - isCompact: boolean; - placeholder?: string; -} - -export interface HistoryConfig { - currentDraft: PromptDraftState; - entries: readonly PromptDraftState[]; - onSelectEntry: (draft: PromptDraftState) => void; - resetKey?: string | number; -} - -export type PromptVoiceState = "idle" | "recording" | "transcribing" | "error"; - -export interface PromptVoiceConfig { - state: PromptVoiceState; - isSupported: boolean; - stream: MediaStream | null; - start: () => void | Promise; - stop: () => void; - cancel: () => void; -} - -export interface PromptBoxHandle { - /** Focus the editor and move the caret to the end. */ - focusEnd: () => void; - /** Capture the current card height before a controlled layout change. */ - captureHeightForLayoutChange: () => void; - /** Insert text at the editor's current cursor position, with smart spacing. */ - insertTextAtCursor: (text: string) => void; - /** Return the trimmed text before the cursor, used as voice transcript context. */ - getTextBeforeCursor: () => string | undefined; -} - -export type { PromptBoxAction } from "./PromptBoxActionsMenu"; - -export type MentionMenuPlacement = "top" | "bottom"; - -export interface PromptBoxInternalProps { - id?: string; - value: string; - mentionRanges: readonly PromptTextMention[]; - onChange: (value: string, mentionRanges: PromptTextMention[]) => void; - onSubmit: () => void; - placeholder?: string; - className?: string; - /** Plugin-owned whole-draft paint sources, in deterministic composition order. */ - textEffects?: readonly ComposerTextEffectSource[]; - /** Publishes the editor-owned layout to the concrete composer shell. */ - onComposerLayoutChange?: (layout: ComposerView["layout"]) => void; - /** Content rendered inside the prompt box card, above the text area. Use - * for prominent context that should be impossible to miss — e.g. a - * "Reusing existing worktree" banner when env mode is set to reuse. */ - header?: ReactNode; - footerStart?: ReactNode; - submission?: PromptBoxSubmissionConfig; - /** - * Minimum textarea height in pixels. Defaults to PROMPTBOX_MIN_HEIGHT. - * Callers may pass a smaller value to make room for siblings that grow - * above the textarea (see FollowUpPromptBox's elastic compensation for - * the context banner stack) — total prompt-area height stays constant. - */ - minHeight?: number; - typeahead: TypeaheadConfig; - /** - * Where the typeahead menu floats relative to the prompt box. - * "top" floats it above (used by FollowUp where the prompt sits at the - * bottom of the thread), "bottom" floats it below (used by NewThread - * where the prompt sits at the top of the project view). - */ - mentionMenuPlacement: MentionMenuPlacement; - attachments?: AttachmentsConfig; - promptActions?: readonly PromptBoxAction[]; - /** Suppress plugin composer regions without unmounting the editor. */ - suppressPluginComposerCustomizations?: boolean; - zenMode?: PromptBoxZenModeConfig; - /** Optional one-line presentation for unfocused mobile follow-up composers. */ - compact?: PromptBoxCompactConfig; - /** Compact placeholder used when a follow-up composer is narrowed by its container. */ - containerCompactPlaceholder?: string; - /** - * Changing this after captureHeightForLayoutChange() animates a layout - * change that is driven outside this component, such as a container query. - */ - heightAnimationKey?: string | number; - history?: HistoryConfig; - /** When omitted, the mic button is hidden. Wrappers wire this via usePromptVoice. */ - voice?: PromptVoiceConfig; - promptBoxRef?: Ref; - /** - * Changing this re-focuses the editor caret to the end. Used by explicit - * draft-restore actions (e.g. editing a queued message) so the user can type - * immediately. Unlike the scope autofocus it fires even on coarse pointers, - * since it follows a deliberate click. - */ - focusEndKey?: string | number; -} - -interface DismissedTriggerRange { - start: number; - end: number; - hasLeftRange: boolean; -} - -interface PromptEditorValueKey { - text: string; - mentions: readonly PromptTextMention[]; -} - -const DEFAULT_TYPEAHEAD_MENTION_TRIGGERS = [ - DEFAULT_PLUGIN_MENTION_TRIGGER, -] as const satisfies readonly PluginMentionTrigger[]; - -interface PromptEditorSelectionRevealArgs { - editor: Editor; - scrollContainer: HTMLElement; -} - -interface ParsedRichClipboardValue { - hasMentions: boolean; - value: PromptEditorValue; -} - -type ZenModeUpdate = - | boolean - | typeof RESET - | ((previous: boolean) => boolean | typeof RESET); - -type PromptBoxMouseDownEvent = ReactMouseEvent; - -interface PromptActionInsertionRange { - from: number; - 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(","); - -function createTransientZenModeAtom() { - const baseAtom = atom(false); - return atom( - (get) => get(baseAtom), - (get, set, update: ZenModeUpdate) => { - const currentValue = get(baseAtom); - const nextValue = - typeof update === "function" ? update(currentValue) : update; - - set(baseAtom, nextValue === RESET ? false : nextValue); - }, - ); -} - -function promptEditorValueKey(value: PromptEditorValueKey): string { - return JSON.stringify(value); -} - -function normalizePastedPlainText(text: string): string { - return text.replace(/\r\n?/gu, "\n"); -} - -function promptActionCommandMentionsFromText( - text: string, - actions: readonly PromptBoxAction[] | undefined, -): PromptTextMention[] { - const mentions: PromptTextMention[] = []; - - for (const action of actions ?? []) { - const commandAction = promptActionCommandFromAction(action); - if (commandAction === null) { - continue; - } - - let searchStart = 0; - while (searchStart < text.length) { - const start = text.indexOf(commandAction.serializedText, searchStart); - if (start === -1) { - break; - } - - const end = start + commandAction.serializedText.length; - const before = start === 0 ? "" : text[start - 1]!; - const after = end >= text.length ? "" : text[end]!; - const hasTokenBoundaryBefore = before === "" || /\s/u.test(before); - const hasTokenBoundaryAfter = after === "" || /\s/u.test(after); - - if (hasTokenBoundaryBefore && hasTokenBoundaryAfter) { - mentions.push({ - start, - end, - resource: promptCommandResourceFromSuggestion({ - suggestion: commandAction.suggestion, - trigger: commandAction.trigger, - }), - }); - } - - searchStart = end; - } - } - - return mentions.sort( - (left, right) => left.start - right.start || left.end - right.end, - ); -} - -function mergePromptTextMentions( - baseMentions: readonly PromptTextMention[], - additionalMentions: readonly PromptTextMention[], -): PromptTextMention[] { - const merged = [...baseMentions].sort( - (left, right) => left.start - right.start || left.end - right.end, - ); - - for (const additionalMention of additionalMentions) { - const overlapsExisting = merged.some( - (mention) => - additionalMention.start < mention.end && - additionalMention.end > mention.start, - ); - if (!overlapsExisting) { - merged.push(additionalMention); - } - } - - return merged.sort( - (left, right) => left.start - right.start || left.end - right.end, - ); -} - -function withPromptActionCommandMentions( - value: PromptEditorValue, - promptActions: readonly PromptBoxAction[] | undefined, -): PromptEditorValue { - const promptActionMentions = promptActionCommandMentionsFromText( - value.text, - promptActions, - ); - if (promptActionMentions.length === 0) { - return value; - } - - return { - ...value, - mentions: mergePromptTextMentions(value.mentions, promptActionMentions), - }; -} - -function promptEditorValueFromPlainText( - text: string, - promptActions?: readonly PromptBoxAction[], -): PromptEditorValue { - const normalizedText = normalizePastedPlainText(text); - return withPromptActionCommandMentions( - { - text: normalizedText, - mentions: [], - }, - promptActions, - ); -} - -function promptEditorSliceHasBlockquote(slice: Slice): boolean { - let hasBlockquote = false; - slice.content.descendants((node) => { - if (node.type.name === "blockquote") { - hasBlockquote = true; - return false; - } - return true; - }); - return hasBlockquote; -} - -function plainTextHasQuoteLine(text: string): boolean { - return normalizePastedPlainText(text) - .split("\n") - .some((line) => line === ">" || line.startsWith("> ")); -} - -function trimTrailingPromptNewlines( - value: PromptEditorValue, -): PromptEditorValue { - const text = value.text.replace(/\n+$/u, ""); - if (text.length === value.text.length) { - return value; - } - - return { - text, - mentions: value.mentions.filter((mention) => mention.end <= text.length), - }; -} - -function promptEditorValueFromRichHtml(html: string): ParsedRichClipboardValue { - const document = new DOMParser().parseFromString(html, "text/html"); - let text = ""; - let hasMentions = false; - const mentions: PromptTextMention[] = []; - - const appendNewline = () => { - text = text.replace(/[ \t]+$/u, ""); - if (text.length > 0 && !text.endsWith("\n")) { - text += "\n"; - } - }; - - const appendCollapsedText = (rawText: string) => { - const collapsedText = rawText.replace(/\s+/gu, " "); - if (collapsedText.trim().length === 0) { - if (text.length > 0 && !/[\s]$/u.test(text)) { - text += " "; - } - return; - } - text += collapsedText; - }; - - const appendClipboardMention = (element: Element): boolean => { - const payload = parsePromptMentionClipboardElement({ element }); - if (!payload) { - return false; - } - - const start = text.length; - text += payload.serializedText; - mentions.push({ - start, - end: text.length, - resource: payload.resource, - }); - hasMentions = true; - return true; - }; - - const visitChildren = (node: Node, preserveWhitespace: boolean) => { - for (const childNode of node.childNodes) { - visitNode(childNode, preserveWhitespace); - } - }; - - const visitNode = (node: Node, preserveWhitespace: boolean) => { - if (node.nodeType === Node.TEXT_NODE) { - const rawText = node.textContent ?? ""; - if (preserveWhitespace) { - text += normalizePastedPlainText(rawText); - return; - } - appendCollapsedText(rawText); - return; - } - - if (!(node instanceof Element)) { - visitChildren(node, preserveWhitespace); - return; - } - - const tagName = node.tagName.toUpperCase(); - if (RICH_PASTE_IGNORED_TAGS.has(tagName)) { - return; - } - if (appendClipboardMention(node)) { - return; - } - if (tagName === "BR") { - appendNewline(); - return; - } - if (tagName === "PRE") { - appendNewline(); - text += normalizePastedPlainText(node.textContent ?? ""); - appendNewline(); - return; - } - if (tagName === "LI") { - appendNewline(); - text += "- "; - visitChildren(node, preserveWhitespace); - appendNewline(); - return; - } - if (RICH_PASTE_BLOCK_TAGS.has(tagName)) { - appendNewline(); - visitChildren(node, preserveWhitespace); - appendNewline(); - return; - } - - visitChildren(node, preserveWhitespace); - }; - - visitChildren(document.body, false); - - if (hasMentions) { - const trimmedText = text.replace(/\n+$/u, ""); - return { - hasMentions, - value: { - text: trimmedText, - mentions: mentions.filter( - (mention) => - mention.start >= 0 && - mention.end > mention.start && - mention.end <= trimmedText.length, - ), - }, - }; - } - - return { - hasMentions, - value: { - text: text - .replace(/[ \t]+\n/gu, "\n") - .replace(/\n{3,}/gu, "\n\n") - .replace(/^\n+/u, "") - .replace(/\n+$/u, ""), - mentions: [], - }, - }; -} - -function promptEditorValueFromClipboardPaste( - clipboardData: DataTransfer | null, - promptActions?: readonly PromptBoxAction[], -): PromptEditorValue | null { - const html = clipboardData?.getData("text/html") ?? ""; - const hasHtml = html.trim().length > 0; - if (hasHtml) { - const richValue = promptEditorValueFromRichHtml(html); - if (richValue.hasMentions) { - return withPromptActionCommandMentions(richValue.value, promptActions); - } - } - - const plainText = clipboardData?.getData("text/plain") ?? ""; - if (plainText.length > 0) { - return promptEditorValueFromPlainText(plainText, promptActions); - } - - if (!hasHtml) { - return null; - } - - return promptEditorValueFromRichHtml(html).value; -} - -function runAfterClipboardCut(callback: () => void): void { - if (typeof queueMicrotask === "function") { - queueMicrotask(callback); - return; - } - - setTimeout(callback, 0); -} - -function revealPromptEditorSelection({ - editor, - scrollContainer, -}: PromptEditorSelectionRevealArgs): void { - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - if (scrollContainerRect.height <= 0) return; - - let selectionRect: ReturnType; - try { - selectionRect = editor.view.coordsAtPos(editor.state.selection.to); - } catch { - return; - } - - const topOverflow = - selectionRect.top - - scrollContainerRect.top - - PROMPTBOX_SELECTION_REVEAL_MARGIN; - if (topOverflow < 0) { - scrollContainer.scrollTop = Math.max( - 0, - scrollContainer.scrollTop + topOverflow, - ); - return; - } - - const bottomOverflow = - selectionRect.bottom - - scrollContainerRect.bottom + - PROMPTBOX_SELECTION_REVEAL_MARGIN; - if (bottomOverflow > 0) { - scrollContainer.scrollTop += bottomOverflow; - } -} - -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, -): boolean { - if (!editor.state.selection.empty) { - return false; - } - - const before = editor.state.doc.textBetween( - 0, - editor.state.selection.from, - "\n", - "\n", - ); - 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[], -): boolean { - if (node.type.name !== "mention") { - return false; - } - const attrs = parsePromptEditorMentionAttrs(node.attrs); - if (!attrs || attrs.resource.kind !== "command") { - return false; - } - const resource = attrs.resource; - return actions.some((action) => { - const command = action.command; - if (!command) { - return false; - } - return ( - resource.trigger === command.trigger && - resource.name === command.name && - attrs.serializedText === promptActionCommandSerializedText(action) - ); - }); -} - -function findPromptActionTextSuffix( - text: string, - actions: readonly PromptBoxAction[], -): PromptBoxAction | null { - return ( - actions.find( - (action) => - !action.command && action.text.length > 0 && text.endsWith(action.text), - ) ?? null - ); -} - -function getPromptActionRangeImmediatelyBeforeCursor({ - editor, - actions, -}: { - editor: Editor; - actions: readonly PromptBoxAction[]; -}): PromptActionInsertionRange | null { - const selection = editor.state.selection; - if (!selection.empty) { - return null; - } - - const { $from } = selection; - const cursorOffset = $from.parentOffset; - const parentStart = $from.start(); - let searchOffset = cursorOffset; - - while (searchOffset > 0) { - const previous = $from.parent.childBefore(searchOffset); - const node = previous.node; - if (!node) { - return null; - } - const sizeBeforeSearchOffset = searchOffset - previous.offset; - if (node.isText) { - const textBeforeCursor = (node.text ?? "").slice( - 0, - sizeBeforeSearchOffset, - ); - const textAction = findPromptActionTextSuffix(textBeforeCursor, actions); - if (textAction) { - return { - from: - parentStart + - previous.offset + - textBeforeCursor.length - - textAction.text.length, - to: selection.from, - }; - } - if (/\S/u.test(textBeforeCursor)) { - return null; - } - searchOffset = previous.offset; - continue; - } - if ( - sizeBeforeSearchOffset === node.nodeSize && - isPromptActionCommandMention(node, actions) - ) { - return { - from: parentStart + previous.offset, - to: selection.from, - }; - } - return null; - } - - return null; -} - -function getPromptActionInsertionRange({ - editor, - action, - actions, - triggers, -}: { - editor: Editor; - action: PromptBoxAction; - actions: readonly PromptBoxAction[]; - triggers: readonly TypeaheadTrigger[]; -}): PromptActionInsertionRange | null { - const selection = editor.state.selection; - if (!selection.empty) { - return { from: selection.from, to: selection.to }; - } - - const previousPromptActionRange = getPromptActionRangeImmediatelyBeforeCursor( - { - editor, - actions, - }, - ); - if (previousPromptActionRange !== null) { - return previousPromptActionRange; - } - - const activeCommandTrigger = findActiveTrigger(editor, triggers); - const isActiveCommand = - activeCommandTrigger !== null && activeCommandTrigger.kind === "command"; - - if (action.kind === "skills") { - if ( - isActiveCommand && - activeCommandTrigger.char === action.text && - activeCommandTrigger.to === selection.from - ) { - return null; - } - return { from: selection.from, to: selection.to }; - } - - if (isActiveCommand && activeCommandTrigger.to === selection.from) { - return { - from: activeCommandTrigger.from, - to: activeCommandTrigger.to, - }; - } - - 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, -): readonly TypeaheadTrigger[] { - if (commandAction === null) { - return triggers; - } - if ( - triggers.some( - (trigger) => - trigger.kind === "command" && trigger.char === commandAction.trigger, - ) - ) { - return triggers; - } - return [ - ...triggers, - { kind: "command", char: commandAction.trigger }, - ] satisfies TypeaheadTrigger[]; -} - -export function suppressPromptEditorAnchorActivation(event: Event): boolean { - if (!(event.target instanceof Element)) return false; - if (event.target.closest("a[href]") === null) return false; - - event.preventDefault(); - event.stopPropagation(); - return true; -} - -// TipTap's `blur` command defers to the next animation frame, so blur the -// editor DOM directly and drop the caret with it. -function blurPromptEditor(editor: Editor | null | undefined): void { - editor?.view.dom.blur(); - window.getSelection()?.removeAllRanges(); -} - -function focusEditorAtEnd(editor: Editor): void { - const transaction = editor.state.tr - .setSelection(TextSelection.atEnd(editor.state.doc)) - .scrollIntoView(); - editor.view.dispatch(transaction); - editor.view.focus(); -} - -const SAFARI_POST_COMPOSITION_KEYDOWN_WINDOW_MS = 500; - -function isIPadOSWebKit(): boolean { - if (typeof navigator === "undefined") return false; - - const isAppleWebKit = - /Apple Computer/u.test(navigator.vendor) && - /\bAppleWebKit\//u.test(navigator.userAgent); - const isIPad = - navigator.platform === "iPad" || - (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 2); - return isAppleWebKit && isIPad; -} - -/** - * Holds the keydown events that the iPadOS hook refused as an IME candidate - * confirmation, so the normal key handler refuses them too. The set is keyed on - * the event object, so entries disappear with the events themselves. - */ -function usePostCompositionKeyDownEvents(): WeakSet { - const ref = useRef | null>(null); - ref.current ??= new WeakSet(); - return ref.current; -} - -function isIPadHardwareEnterCandidate(event: KeyboardEvent): boolean { - return ( - event.key === "Enter" && - (event.code === "Enter" || event.code === "NumpadEnter") - ); -} - -export function PromptBoxInternal({ - id, - value, - mentionRanges, - onChange, - onSubmit, - placeholder = "Ask anything. @ to mention files, folders, or sections", +function PromptBoxFallback({ className, - textEffects, - onComposerLayoutChange, - header, - footerStart, - submission = {}, - minHeight = PROMPTBOX_MIN_HEIGHT, - typeahead, - mentionMenuPlacement, - attachments: attachmentConfig = {}, - promptActions, - suppressPluginComposerCustomizations = false, - zenMode = {}, compact, - containerCompactPlaceholder, - heightAnimationKey, - history, - voice, - promptBoxRef, - focusEndKey, + header, + minHeight = 68, + placeholder = "Ask anything. @ to mention files, folders, or sections", }: PromptBoxInternalProps) { - const focusComposerShortcut = useAppCommandShortcut("composer.focus"); - const { - isSubmitting = false, - disabled: submitDisabled = false, - title: submitTitle = "Submit (Enter)", - isRunning = false, - onStop, - onModifierSubmit, - } = submission; - const { - triggers: mentionTriggerChars = DEFAULT_TYPEAHEAD_MENTION_TRIGGERS, - suggestions: mentionSuggestions, - isLoading: mentionLoading, - isError: mentionError, - onQueryChange: onMentionQueryChange, - resolveLink: mentionResolveLink, - } = typeahead.mention; - const { - trigger: commandTriggerChar, - suggestions: commandSuggestions, - isLoading: commandLoading, - isError: commandError, - onQueryChange: onCommandQueryChange, - } = typeahead.command; - const { - items: attachments = [], - isAttaching = false, - error: attachmentError = null, - onAttachFiles, - onRemove: onRemoveAttachment, - projectId: attachmentProjectId, - } = attachmentConfig; - const { - layout: zenModeLayout = "thread", - storageKey: zenModeStorageKey, - resetKey: zenModeResetKey, - resetOnSubmit: resetZenModeOnSubmit = false, - } = zenMode; - const isPointerCoarse = usePointerCoarse(); - // Legacy iPads report an iPad platform; current iPadOS WebKit uses a - // desktop-like MacIntel platform with touch points distinguishing it from - // macOS. The value is stable for the lifetime of the page, so it does not - // need another media-query listener. - const isIPadOSWebKitDevice = useMemo(isIPadOSWebKit, []); - const editorEnterKeyHint = isPointerCoarse ? "enter" : "send"; - // Passive text autofocus opens the soft keyboard on coarse-pointer devices. - const shouldAvoidSoftKeyboardAutofocus = isPointerCoarse; - const formRef = useRef(null); - const heightAnimationFromRef = useRef(null); - const capturePromptBoxHeight = useCallback(() => { - const formElement = formRef.current; - heightAnimationFromRef.current = - formElement?.getBoundingClientRect().height ?? null; - }, []); - 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 editorRef = useRef(null); - const editorScrollContainerRef = useRef(null); - const revealSelectionFrameRef = useRef(null); - const promptActionFocusFrameRef = useRef(null); - const pendingFocusEndRef = useRef(false); - const attachmentInputRef = useRef(null); - const valueRef = useRef(value); - const mentionRangesRef = useRef(mentionRanges); - const placeholderRef = useRef(placeholder); - const skipEditorChangeRef = useRef(false); - const editorValueKeyRef = useRef(""); - const triggerKeyRef = useRef(""); - const handleEditorKeyDownRef = useRef< - (event: KeyboardEvent, isOriginalIPadHardwareEnter?: boolean) => boolean - >(() => false); - const compositionEndedAtRef = useRef(Number.NEGATIVE_INFINITY); - const postCompositionKeyDownEvents = usePostCompositionKeyDownEvents(); - const dispatchAppCommandKey = useAppCommandKeyDispatch(); - // The TipTap editor is created once; its `onUpdate`/`onSelectionUpdate`/click - // handlers close over the first `syncTriggerState`. `syncTriggerState` - // depends on the active trigger set, which changes when the thread's provider - // (command trigger) changes — so route those handlers through a ref kept - // pointed at the latest closure, mirroring `handleEditorKeyDownRef`. - const syncTriggerStateRef = useRef<(editor: Editor) => void>(() => {}); - const onAttachFilesRef = useRef(onAttachFiles); - const dismissedTriggerRef = useRef(null); - const isRestoringAppliedMentionRef = useRef(false); - const [activeTrigger, setActiveTrigger] = useState( - null, - ); - const [selectedIndex, setSelectedIndex] = useState(0); - const [expandedImageIndex, setExpandedImageIndex] = useState( - null, - ); - const [activeHistoryIndex, setActiveHistoryIndex] = useState( - null, - ); - const [temporaryHistoryDraft, setTemporaryHistoryDraft] = - useState(null); - const [recalledHistoryDraft, setRecalledHistoryDraft] = - useState(null); - const resolvedZenModeStorageKey = - zenModeStorageKey ?? ZEN_MODE_STORAGE_KEY[zenModeLayout]; - const zenModeAtom = useMemo( - () => - resolvedZenModeStorageKey - ? atomWithStorage( - resolvedZenModeStorageKey, - false, - createJsonLocalStorage(), - { - getOnInit: true, - }, - ) - : createTransientZenModeAtom(), - [resolvedZenModeStorageKey], - ); - const [isZenMode, setIsZenMode] = useAtom(zenModeAtom); - const isVoiceRecording = voice?.state === "recording"; - const isVoiceProcessing = voice?.state === "transcribing"; - const showVoiceActionGroup = isVoiceRecording || isVoiceProcessing; - const isVoiceBusy = showVoiceActionGroup; - // Zen styling is suppressed while the voice bar shows, since the box - // collapses to the pill instead. - const showZenLayout = isZenMode && !showVoiceActionGroup; - const showCompactLayout = - compact?.isCompact === true && !showVoiceActionGroup && !isZenMode; - const effectivePlaceholder = showCompactLayout - ? (compact.placeholder ?? placeholder) - : placeholder; - const pluginComposerHost = usePluginComposerHost(); - const { composerCustomizations } = usePluginSlots(); - const composerInputLocked = useComposerInputLock( - pluginComposerHost?.textEffectKey ?? null, - ); - const composerLayout = showCompactLayout - ? "compact" - : showZenLayout - ? "zen" - : "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; - const composerViewRef = useRef(composerView); - composerViewRef.current = composerView; - useEffect(() => { - onComposerLayoutChange?.(composerLayout); - }, [composerLayout, onComposerLayoutChange]); - const pluginRichTextContributions = useMemo(() => { - if (suppressPluginComposerCustomizations) { - return { - sources: [] as readonly PromptDecorationSource[], - observers: [] as readonly PromptDraftObserver[], - }; - } - - const sources: PromptDecorationSource[] = []; - const observers: PromptDraftObserver[] = []; - for (const customization of composerCustomizationsForScope( - composerCustomizations, - composerView.scope.kind, - )) { - const richText = customization.richText; - if (richText === undefined) continue; - const sourceId = `${customization.pluginId}/${customization.id}`; - if (richText.effects !== undefined && richText.effects.length > 0) { - sources.push({ - id: sourceId, - generation: customization.generation, - pluginId: customization.pluginId, - effects: richText.effects, - }); - } - if (richText.onDraftChange !== undefined) { - observers.push({ - id: sourceId, - getView: () => composerViewRef.current, - onDraftChange: richText.onDraftChange, - }); - } - } - for (const effectSource of textEffects ?? []) { - const className = effectSource.effect.className; - if (className.length === 0) continue; - sources.push({ - id: `plugin-imperative:${effectSource.pluginId}:${effectSource.order}`, - generation: effectSource.order, - pluginId: effectSource.pluginId, - effects: [ - { - id: "whole-draft", - className, - match: (text) => - text.length === 0 ? [] : [{ from: 0, to: text.length }], - }, - ], - }); - } - return { sources, observers }; - }, [ - composerCustomizations, - composerView.scope, - suppressPluginComposerCustomizations, - textEffects, - ]); - const pluginDecorationSourcesRef = useRef( - pluginRichTextContributions.sources, - ); - pluginDecorationSourcesRef.current = pluginRichTextContributions.sources; - const pluginDraftObserversRef = useRef(pluginRichTextContributions.observers); - pluginDraftObserversRef.current = pluginRichTextContributions.observers; - const pluginPlusMenuItems = - usePluginComposerPlusMenuContributions(composerView); - const focusScopeKey = history?.resetKey; - const onChangeRef = useRef(onChange); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - useEffect(() => { - onAttachFilesRef.current = onAttachFiles; - }, [onAttachFiles]); - - const revealEditorSelection = useCallback(() => { - const currentEditor = editorRef.current; - const scrollContainer = editorScrollContainerRef.current; - if (!currentEditor || currentEditor.isDestroyed || !scrollContainer) return; - - revealPromptEditorSelection({ - editor: currentEditor, - scrollContainer, - }); - }, []); - - const scheduleRevealEditorSelection = useCallback(() => { - if (typeof requestAnimationFrame !== "function") { - revealEditorSelection(); - return; - } - - if (revealSelectionFrameRef.current !== null) { - cancelAnimationFrame(revealSelectionFrameRef.current); - } - - revealSelectionFrameRef.current = requestAnimationFrame(() => { - revealSelectionFrameRef.current = null; - revealEditorSelection(); - }); - }, [revealEditorSelection]); - - useEffect(() => { - return () => { - if (revealSelectionFrameRef.current === null) return; - cancelAnimationFrame(revealSelectionFrameRef.current); - }; - }, []); - - useEffect(() => { - return () => { - if (promptActionFocusFrameRef.current === null) return; - cancelAnimationFrame(promptActionFocusFrameRef.current); - }; - }, []); - - // Active trigger set: mention triggers are always watched; the provider's - // command trigger joins them when present. - const triggers = useMemo(() => { - const mentionTriggers = mentionTriggerChars.map((char) => ({ - char, - kind: "mention" as const, - })); - if (commandTriggerChar === null) { - return mentionTriggers; - } - return [...mentionTriggers, { char: commandTriggerChar, kind: "command" }]; - }, [commandTriggerChar, mentionTriggerChars]); - - // Fan the active query out to the matching data source and null the other, - // so switching from `@foo` to `/bar` (or vice versa) clears the stale query. - const dispatchTriggerQuery = useCallback( - (active: ActiveTrigger | null) => { - if (active?.kind === "mention") { - onMentionQueryChange(active.query, active.char); - onCommandQueryChange(null); - return; - } - if (active?.kind === "command") { - onCommandQueryChange(active.query); - onMentionQueryChange(null, null); - return; - } - onMentionQueryChange(null, null); - onCommandQueryChange(null); - }, - [onCommandQueryChange, onMentionQueryChange], - ); - - const syncTriggerState = useCallback( - (editor: Editor) => { - const caretPosition = editor.state.selection.from; - const dismissedTrigger = dismissedTriggerRef.current; - const isRestoringAppliedMention = - isRestoringAppliedMentionRef.current && dismissedTrigger !== null; - - if (dismissedTrigger && !isRestoringAppliedMention) { - const isWithinDismissedRange = - caretPosition >= dismissedTrigger.start && - caretPosition <= dismissedTrigger.end; - - if (!isWithinDismissedRange) { - dismissedTriggerRef.current = { - ...dismissedTrigger, - hasLeftRange: true, - }; - } else if (dismissedTrigger.hasLeftRange) { - dismissedTriggerRef.current = null; - } - } - - const shouldSuppressTrigger = Boolean( - dismissedTriggerRef.current && - !dismissedTriggerRef.current.hasLeftRange && - (isRestoringAppliedMention || - (caretPosition >= dismissedTriggerRef.current.start && - caretPosition <= dismissedTriggerRef.current.end)), - ); - - const nextTrigger = shouldSuppressTrigger - ? null - : findActiveTrigger(editor, triggers); - const nextKey = nextTrigger - ? `${nextTrigger.kind}:${nextTrigger.from}:${nextTrigger.to}:${nextTrigger.query}` - : ""; - if (nextKey !== triggerKeyRef.current) { - triggerKeyRef.current = nextKey; - setSelectedIndex(0); - } - setActiveTrigger(nextTrigger); - - dispatchTriggerQuery(nextTrigger); - }, - [dispatchTriggerQuery, triggers], - ); - - useEffect(() => { - syncTriggerStateRef.current = syncTriggerState; - }, [syncTriggerState]); - - // Markdown rich-text formatting (headings/lists/marks + their live input - // rules) is opt-in; the default-OFF preference keeps the prompt box plain - // text. Toggling rebuilds the editor (see the `[richTextEditing]` deps below) - // so the schema and input rules switch immediately. - const [richTextEditing] = useRichTextEditingPreference(); - const editorExtensions = useMemo( - () => - promptEditorExtensions({ - richTextEditing, - getPlaceholder: () => placeholderRef.current, - getDecorationSources: () => pluginDecorationSourcesRef.current, - getDraftObservers: () => pluginDraftObserversRef.current, - }), - [richTextEditing], - ); - - const editor = useEditor( - { - extensions: editorExtensions, - content: promptEditorContentFromValue( - { - text: value, - mentions: mentionRanges, - }, - { richTextMarkdown: richTextEditing }, - ), - immediatelyRender: false, - editorProps: { - attributes: { - "aria-label": effectivePlaceholder, - "data-placeholder": effectivePlaceholder, - ...(onModifierSubmit ? { "aria-keyshortcuts": "Meta+Enter" } : {}), - autocomplete: "off", - class: cn( - "min-h-full whitespace-pre-wrap break-words outline-none", - "placeholder:select-none placeholder:text-subtle-foreground", - ), - enterkeyhint: editorEnterKeyHint, - ...(id ? { id } : {}), - role: "textbox", - }, - clipboardTextSerializer: (slice, view) => - promptEditorClipboardTextFromSlice(slice, view.state.schema), - handleDOMEvents: { - auxclick: (_view, event) => { - return suppressPromptEditorAnchorActivation(event); - }, - blur: () => { - triggerKeyRef.current = ""; - if (dismissedTriggerRef.current) { - dismissedTriggerRef.current = { - ...dismissedTriggerRef.current, - hasLeftRange: true, - }; - } - setActiveTrigger(null); - onMentionQueryChange(null, null); - onCommandQueryChange(null); - return false; - }, - cut: () => { - runAfterClipboardCut(() => { - const currentEditor = editorRef.current; - if (!currentEditor || currentEditor.isDestroyed) return; - removeEmptyBlockquotes(currentEditor); - }); - return false; - }, - compositionend: (_view, event) => { - // ProseMirror records this timestamp only while it considers - // itself composing. Record it on the same condition, or a - // `compositionend` outside a composition would suppress a real - // Magic Keyboard Enter for the next 500 ms. - if (!_view.composing) return false; - compositionEndedAtRef.current = event.timeStamp; - return false; - }, - keydown: (_view, event) => { - if ( - !_view.editable || - !isIPadOSWebKitDevice || - !isIPadHardwareEnterCandidate(event) || - _view.composing || - event.isComposing || - event.keyCode === 229 - ) { - return false; - } - - // Match ProseMirror's Safari compositionend -> keydown safeguard. - // This custom DOM hook runs before ProseMirror's own keydown - // handler, so bypassing it here would otherwise submit an IME - // candidate confirmation. - if ( - Math.abs(event.timeStamp - compositionEndedAtRef.current) < - SAFARI_POST_COMPOSITION_KEYDOWN_WINDOW_MS - ) { - compositionEndedAtRef.current = Number.NEGATIVE_INFINITY; - postCompositionKeyDownEvents.add(event); - return false; - } - - // ProseMirror delays iOS Enter handling and later passes a - // synthetic Enter to handleKeyDown so the software keyboard can - // finish its DOM mutation. Only on the affected iPadOS WebKit path - // do we use the original event's physical code to handle a Magic - // Keyboard Enter before that fallback. Other platforms, including - // Android and coarse-pointer hybrids, stay entirely on - // ProseMirror's normal path. - // - // A handled event stops ProseMirror's own `keydown` handler, which - // is also where ProseMirror flushes its DOM observer. That is safe - // here: every deferred-flush path in ProseMirror needs either IE11 - // or an active composition, and the composition check above already - // excludes the second one. So the observer has flushed already and - // the submit reads a current document. - return handleEditorKeyDownRef.current(event, true); - }, - click: (_view, event) => { - return suppressPromptEditorAnchorActivation(event); - }, - }, - handleClick: () => { - const currentEditor = editorRef.current; - if (!currentEditor) return false; - syncTriggerStateRef.current(currentEditor); - return false; - }, - handleKeyDown: (_view, event) => { - return handleEditorKeyDownRef.current(event); - }, - handlePaste: (view, event, slice) => { - const attachFiles = onAttachFilesRef.current; - const clipboardItems = Array.from(event.clipboardData?.items ?? []); - const pastedFiles = clipboardItems - .filter((item) => item.kind === "file") - .map((item) => item.getAsFile()) - .filter((file): file is File => file !== null); - - if (attachFiles && pastedFiles.length > 0) { - event.preventDefault(); - void attachFiles(pastedFiles); - return true; - } - - const plainText = event.clipboardData?.getData("text/plain") ?? ""; - const sliceHasBlockquote = promptEditorSliceHasBlockquote(slice); - if (sliceHasBlockquote || plainTextHasQuoteLine(plainText)) { - event.preventDefault(); - const pastedValue = trimTrailingPromptNewlines( - sliceHasBlockquote - ? promptEditorValueFromSlice(slice, view.state.schema) - : promptEditorValueFromPlainText(plainText, promptActions), - ); - if (pastedValue.text.length === 0) return true; - - const currentEditor = editorRef.current; - const pastedContent = - promptEditorContentFromValue(pastedValue, { - richTextMarkdown: richTextEditing, - }).content ?? []; - currentEditor?.chain().focus().insertContent(pastedContent).run(); - if (currentEditor && !currentEditor.isDestroyed) { - const nextValue = trimTrailingPromptNewlines( - promptEditorValueFromDoc(currentEditor.state.doc), - ); - editorValueKeyRef.current = promptEditorValueKey(nextValue); - onChangeRef.current(nextValue.text, nextValue.mentions); - } - return true; - } - - const pastedValue = promptEditorValueFromClipboardPaste( - event.clipboardData ?? null, - promptActions, - ); - if (pastedValue === null) return false; - - event.preventDefault(); - if (pastedValue.text.length === 0) return true; - - editorRef.current - ?.chain() - .focus() - .insertContent(promptEditorInlineContentFromValue(pastedValue)) - .run(); - return true; - }, - }, - onCreate({ editor: createdEditor }) { - editorRef.current = createdEditor; - editorValueKeyRef.current = promptEditorValueKey({ - text: value, - mentions: mentionRanges, - }); - }, - onSelectionUpdate({ editor: updatedEditor }) { - syncTriggerStateRef.current(updatedEditor); - scheduleRevealEditorSelection(); - }, - onUpdate({ editor: updatedEditor }) { - if (skipEditorChangeRef.current) return; - const nextValue = promptEditorValueFromDoc(updatedEditor.state.doc); - editorValueKeyRef.current = promptEditorValueKey(nextValue); - onChangeRef.current(nextValue.text, nextValue.mentions); - syncTriggerStateRef.current(updatedEditor); - scheduleRevealEditorSelection(); - }, - // Rebuild the editor when the rich-text preference toggles so the schema - // and input rules switch. The editor is otherwise created once; its - // handlers route through refs (above) to stay current without rebuilding. - }, - [richTextEditing], - ); - - useEffect(() => { - if (!editor || editor.isDestroyed) return; - const editable = !composerInputLocked; - if (editor.isEditable !== editable) editor.setEditable(editable); - }, [composerInputLocked, editor]); - - useEffect(() => { - editorRef.current = editor; - }, [editor]); - - useEffect(() => { - if (!editor || editor.isDestroyed) return; - refreshPromptDecorations(editor); - }, [editor, pluginRichTextContributions]); - - useLayoutEffect(() => { - if (!pendingFocusEndRef.current) return; - - if (isPointerCoarse) { - pendingFocusEndRef.current = false; - return; - } - if (!editor) return; - pendingFocusEndRef.current = false; - focusEditorAtEnd(editor); - scheduleRevealEditorSelection(); - }, [editor, isPointerCoarse, scheduleRevealEditorSelection]); - - useLayoutEffect(() => { - placeholderRef.current = effectivePlaceholder; - if (!editor) return; - - editor.view.dom.setAttribute("aria-label", effectivePlaceholder); - editor.view.dom.setAttribute("data-placeholder", effectivePlaceholder); - editor.view.dom.setAttribute("enterkeyhint", editorEnterKeyHint); - editor.view.dispatch(editor.state.tr); - }, [editor, editorEnterKeyHint, effectivePlaceholder]); - - useEffect(() => { - if (shouldAvoidSoftKeyboardAutofocus) return; - if (!editor) return; - - const focusEditor = () => { - if (editor.isDestroyed) return; - focusEditorAtEnd(editor); - scheduleRevealEditorSelection(); - }; - - if (typeof window.requestAnimationFrame !== "function") { - focusEditor(); - return; - } - - const handle = window.requestAnimationFrame(focusEditor); - return () => window.cancelAnimationFrame(handle); - }, [ - editor, - focusScopeKey, - scheduleRevealEditorSelection, - shouldAvoidSoftKeyboardAutofocus, - ]); - - useEffect(() => { - mentionRangesRef.current = mentionRanges; - }, [mentionRanges]); - - useEffect(() => { - valueRef.current = value; - }, [value]); - - useLayoutEffect(() => { - if (!editor) return; - const nextValue = { - text: value, - mentions: mentionRanges, - }; - const nextKey = promptEditorValueKey(nextValue); - if (nextKey === editorValueKeyRef.current) { - return; - } - - try { - skipEditorChangeRef.current = true; - editor.commands.setContent( - promptEditorContentFromValue(nextValue, { - richTextMarkdown: richTextEditing, - }), - ); - editorValueKeyRef.current = nextKey; - } finally { - skipEditorChangeRef.current = false; - } - syncTriggerState(editor); - scheduleRevealEditorSelection(); - }, [ - editor, - mentionRanges, - richTextEditing, - scheduleRevealEditorSelection, - syncTriggerState, - value, - ]); - - // An explicit draft-restore action (e.g. editing a queued message) bumps - // `focusEndKey` so the caret lands at the END of the restored text. It is a - // layout effect defined AFTER the layout content-sync effect above, so the - // editor has already applied `setContent` for the new draft in the same - // commit. Mobile web deliberately does not take focus here: an action that - // opens or updates a composer must not summon the soft keyboard over the - // destination surface. - const lastFocusEndKeyRef = useRef(focusEndKey); - useLayoutEffect(() => { - if (focusEndKey === undefined) return; - if (focusEndKey === lastFocusEndKeyRef.current) return; - if (isPointerCoarse) { - lastFocusEndKeyRef.current = focusEndKey; - return; - } - if (!editor) return; - lastFocusEndKeyRef.current = focusEndKey; - focusEditorAtEnd(editor); - scheduleRevealEditorSelection(); - }, [editor, focusEndKey, isPointerCoarse, scheduleRevealEditorSelection]); - - useEffect(() => { - if (zenModeResetKey === undefined) return; - if (resolvedZenModeStorageKey) { - setIsZenMode(RESET); - return; - } - setIsZenMode(false); - }, [resolvedZenModeStorageKey, setIsZenMode, zenModeResetKey]); - - useLayoutEffect(() => { - scheduleRevealEditorSelection(); - }, [isZenMode, minHeight, scheduleRevealEditorSelection]); - - const resetHistorySession = useCallback(() => { - setActiveHistoryIndex(null); - setTemporaryHistoryDraft(null); - setRecalledHistoryDraft(null); - }, []); - - useEffect(() => { - if (!history) { - resetHistorySession(); - return; - } - if (history.entries.length === 0) { - resetHistorySession(); - return; - } - if ( - activeHistoryIndex !== null && - activeHistoryIndex >= history.entries.length - ) { - resetHistorySession(); - } - }, [activeHistoryIndex, history, resetHistorySession]); - - useEffect(() => { - resetHistorySession(); - }, [history?.resetKey, resetHistorySession]); - - useEffect(() => { - if (!history || activeHistoryIndex === null || !recalledHistoryDraft) { - return; - } - const activeHistoryEntry = history.entries[activeHistoryIndex]; - if ( - !activeHistoryEntry || - !arePromptDraftStatesEqual(activeHistoryEntry, recalledHistoryDraft) - ) { - resetHistorySession(); - return; - } - if (arePromptDraftStatesEqual(history.currentDraft, recalledHistoryDraft)) { - return; - } - resetHistorySession(); - }, [activeHistoryIndex, history, recalledHistoryDraft, resetHistorySession]); - - useLayoutEffect(() => { - const fromHeight = heightAnimationFromRef.current; - const formElement = formRef.current; - if (fromHeight === null || !formElement) return; - heightAnimationFromRef.current = null; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - - const previousTransition = formElement.style.transition; - const previousWillChange = formElement.style.willChange; - const previousOverflow = formElement.style.overflow; - - formElement.style.transition = "none"; - formElement.style.height = ""; - const toHeight = formElement.getBoundingClientRect().height; - if (Math.abs(toHeight - fromHeight) < 0.5) { - formElement.style.transition = previousTransition; - return; - } - formElement.style.height = `${fromHeight}px`; - formElement.getBoundingClientRect(); - // The next layout is already mounted while the card still has its old - // height. Clip it for the whole tween so footer controls are revealed by - // the moving border instead of briefly painting outside the card. - formElement.style.overflow = "hidden"; - formElement.style.willChange = "height"; - formElement.style.transition = - "height 240ms cubic-bezier(0.22, 1, 0.36, 1)"; - formElement.style.height = `${toHeight}px`; - - let isCleanedUp = false; - const cleanup = () => { - if (isCleanedUp) return; - isCleanedUp = true; - formElement.style.transition = previousTransition; - formElement.style.willChange = previousWillChange; - formElement.style.overflow = previousOverflow; - formElement.style.height = ""; - formElement.removeEventListener("transitionend", handleTransitionEnd); - window.clearTimeout(fallbackTimeout); - }; - const handleTransitionEnd = (event: TransitionEvent) => { - if (event.propertyName !== "height") return; - cleanup(); - }; - const fallbackTimeout = window.setTimeout(cleanup, 320); - formElement.addEventListener("transitionend", handleTransitionEnd); - - return cleanup; - }, [heightAnimationKey, isZenMode, showCompactLayout, zenModeLayout]); - - const trimmedValue = value.trim(); - const hasAttachments = attachments.length > 0; - const hasSubmittableInput = trimmedValue.length > 0 || hasAttachments; - - const activeTriggerKind = activeTrigger?.kind ?? null; - const commandHasMore = typeahead.command.hasMore; - const commandIsLoadingMore = typeahead.command.isLoadingMore; - const loadMoreCommands = typeahead.command.loadMore; - const canLoadMoreCommands = - activeTriggerKind === "command" && - canLoadMoreCommandResults({ - hasMore: commandHasMore, - isError: commandError, - isLoadingMore: commandIsLoadingMore, - }); - const orderedCommandSuggestions = useMemo( - () => orderCommandSuggestionsBySection(commandSuggestions), - [commandSuggestions], - ); - // The suggestion list driving keyboard nav + Enter/Tab apply for whichever - // trigger is active. Empty when no trigger is open. Memoized so the keyboard - // handler's useCallback identity is stable across renders. - const activeSuggestions = useMemo( - () => - activeTriggerKind === "command" - ? orderedCommandSuggestions - : activeTriggerKind === "mention" - ? mentionSuggestions - : [], - [activeTriggerKind, mentionSuggestions, orderedCommandSuggestions], - ); - - const activeMentionQuery = - activeTrigger?.kind === "mention" ? activeTrigger.query.trim() : ""; - const mentionMenuState: MentionMenuState = - activeMentionQuery.length === 0 - ? { kind: "hint" } - : mentionLoading - ? { kind: "loading" } - : mentionError - ? { kind: "error" } - : { kind: "results", suggestions: mentionSuggestions }; - - const commandMenuState: CommandMenuState = commandLoading - ? { kind: "loading" } - : commandError - ? { kind: "error" } - : { kind: "results", suggestions: orderedCommandSuggestions }; - - // Loaded-empty suppression (§6): a command trigger with zero loaded results - // (not loading, not error) is literal text — never open the menu. Mention - // triggers always open (they have a hint / "no matches" state). - const isCommandTriggerLiteral = - activeTriggerKind === "command" && - !commandLoading && - !commandError && - commandSuggestions.length === 0; - const isBareNonDefaultMentionTrigger = - activeTrigger?.kind === "mention" && - activeTrigger.char !== DEFAULT_PLUGIN_MENTION_TRIGGER && - activeMentionQuery.length === 0; - const showTypeaheadMenu = - activeTrigger !== null && - !isCommandTriggerLiteral && - !isBareNonDefaultMentionTrigger; - - const typeaheadMenuState: TypeaheadMenuState = - activeTriggerKind === "command" - ? { trigger: "command", state: commandMenuState } - : { trigger: "mention", state: mentionMenuState }; - - useEffect(() => { - if (activeSuggestions.length === 0) { - setSelectedIndex(0); - return; - } - if (selectedIndex >= activeSuggestions.length) { - setSelectedIndex(0); - } - }, [activeSuggestions.length, selectedIndex]); - - useEffect(() => { - if ( - activeTriggerKind !== "command" || - !canLoadMoreCommands || - activeSuggestions.length === 0 - ) { - return; - } - const prefetchIndex = Math.max(0, activeSuggestions.length - 3); - if (selectedIndex >= prefetchIndex) { - loadMoreCommands(); - } - }, [ - activeSuggestions.length, - activeTriggerKind, - canLoadMoreCommands, - loadMoreCommands, - selectedIndex, - ]); - - // After applying any suggestion the editor content changed outside React's - // controlled flow; emit the controlled change, then re-focus, re-sync the - // trigger state, and reveal the caret on the next frame. Shared by the - // mention and command apply paths. - const finishApply = useCallback( - (appliedEditor: Editor) => { - const nextValue = promptEditorValueFromDoc(appliedEditor.state.doc); - editorValueKeyRef.current = promptEditorValueKey(nextValue); - onChangeRef.current(nextValue.text, nextValue.mentions); - - requestAnimationFrame(() => { - const nextEditor = editorRef.current; - if (!nextEditor || nextEditor.isDestroyed) { - isRestoringAppliedMentionRef.current = false; - return; - } - nextEditor.commands.focus(); - syncTriggerState(nextEditor); - scheduleRevealEditorSelection(); - isRestoringAppliedMentionRef.current = false; - }); - }, - [scheduleRevealEditorSelection, syncTriggerState], - ); - - const applyMentionSuggestion = useCallback( - (item: PromptMentionSuggestion) => { - const currentEditor = editorRef.current; - if (!currentEditor || activeTrigger?.kind !== "mention") return; - - const replacement = item.replacement.trim(); - const serializedText = replacement.startsWith(activeTrigger.char) - ? replacement - : `${activeTrigger.char}${replacement}`; - const resource = promptMentionResourceFromSuggestion(item); - const trailingText = hasWhitespaceAfterPosition( - currentEditor.state.doc, - activeTrigger.to, - ) - ? "" - : " "; - triggerKeyRef.current = ""; - // Mention dismissed-range basis is node width: trigger char + the 1-wide - // pill atom in the post-replacement doc (`from` → `from + 2`). Do not - // change — pill re-trigger suppression depends on it. - dismissedTriggerRef.current = { - start: activeTrigger.from, - end: activeTrigger.from + 2, - hasLeftRange: false, - }; - isRestoringAppliedMentionRef.current = true; - setActiveTrigger(null); - setSelectedIndex(0); - onMentionQueryChange(null, null); - - try { - skipEditorChangeRef.current = true; - currentEditor - .chain() - .focus() - .deleteRange({ from: activeTrigger.from, to: activeTrigger.to }) - .insertContent([ - { - type: "mention", - attrs: { - resource, - serializedText, - }, - }, - ...(trailingText ? [{ type: "text", text: trailingText }] : []), - ]) - .run(); - } finally { - skipEditorChangeRef.current = false; - } - finishApply(currentEditor); - }, - [activeTrigger, finishApply, onMentionQueryChange], - ); - - const applyCommandSuggestion = useCallback( - (item: ProviderCommandSuggestion) => { - const currentEditor = editorRef.current; - if (!currentEditor || activeTrigger === null) return; - if (activeTrigger.char !== "/") return; - - const serializedText = `${activeTrigger.char}${item.name}`; - const resource = promptCommandResourceFromSuggestion({ - suggestion: item, - trigger: activeTrigger.char, - }); - const trailingText = hasWhitespaceAfterPosition( - currentEditor.state.doc, - activeTrigger.to, - ) - ? "" - : " "; - triggerKeyRef.current = ""; - // Argument hints render as placeholder decorations, not editor text. - dismissedTriggerRef.current = { - start: activeTrigger.from, - end: commandPillDismissedRangeEnd({ - triggerPosition: activeTrigger.from, - trailingText, - }), - hasLeftRange: false, - }; - isRestoringAppliedMentionRef.current = true; - setActiveTrigger(null); - setSelectedIndex(0); - onCommandQueryChange(null); - - try { - skipEditorChangeRef.current = true; - currentEditor - .chain() - .focus() - .deleteRange({ from: activeTrigger.from, to: activeTrigger.to }) - .insertContent([ - { - type: "mention", - attrs: { - resource, - serializedText, - }, - }, - ...(trailingText ? [{ type: "text", text: trailingText }] : []), - ]) - .run(); - } finally { - skipEditorChangeRef.current = false; - } - finishApply(currentEditor); - }, - [activeTrigger, finishApply, onCommandQueryChange], - ); - - const applyTrigger = useCallback( - (item: TypeaheadSuggestion) => { - if (item.kind === "command") { - applyCommandSuggestion(item); - return; - } - applyMentionSuggestion(item); - }, - [applyCommandSuggestion, applyMentionSuggestion], - ); - - const focusEnd = useCallback(() => { - if (isPointerCoarse) { - pendingFocusEndRef.current = false; - return; - } - const currentEditor = editorRef.current; - if (!currentEditor || currentEditor.isDestroyed) { - pendingFocusEndRef.current = true; - return; - } - pendingFocusEndRef.current = false; - focusEditorAtEnd(currentEditor); - scheduleRevealEditorSelection(); - }, [isPointerCoarse, scheduleRevealEditorSelection]); - - const insertTextAtCursor = useCallback( - (rawText: string) => { - const normalizedText = rawText.replace(/\s+/g, " ").trim(); - if (normalizedText.length === 0) return; - - const currentEditor = editorRef.current; - const currentValue = valueRef.current; - if (!currentEditor) { - const nextValue = - currentValue.length === 0 || /\s$/.test(currentValue) - ? `${currentValue}${normalizedText}` - : `${currentValue} ${normalizedText}`; - onChangeRef.current(nextValue, [...mentionRangesRef.current]); - return; - } - - const selection = currentEditor.state.selection; - const before = currentEditor.state.doc.textBetween( - 0, - selection.from, - "\n", - "\n", - ); - const after = currentEditor.state.doc.textBetween( - selection.to, - currentEditor.state.doc.content.size, - "\n", - "\n", - ); - const needsLeadingWhitespace = before.length > 0 && !/\s$/.test(before); - const needsTrailingWhitespace = after.length > 0 && !/^\s/.test(after); - const insertedText = `${needsLeadingWhitespace ? " " : ""}${normalizedText}${needsTrailingWhitespace ? " " : ""}`; - - const insertion = currentEditor.chain(); - if (!isPointerCoarse) insertion.focus(); - insertion.insertContent(insertedText).run(); - if (!isPointerCoarse) scheduleRevealEditorSelection(); - }, - [isPointerCoarse, scheduleRevealEditorSelection], - ); - - const focusAfterPromptAction = useCallback( - (currentEditor: Editor) => { - const focusEditor = () => { - promptActionFocusFrameRef.current = null; - if (currentEditor.isDestroyed) return; - currentEditor.commands.focus(); - syncTriggerState(currentEditor); - scheduleRevealEditorSelection(); - }; - - if (typeof requestAnimationFrame !== "function") { - focusEditor(); - return; - } - - if (promptActionFocusFrameRef.current !== null) { - cancelAnimationFrame(promptActionFocusFrameRef.current); - } - promptActionFocusFrameRef.current = requestAnimationFrame(focusEditor); - }, - [scheduleRevealEditorSelection, syncTriggerState], - ); - - const applyPromptAction = useCallback( - (action: PromptBoxAction) => { - if (action.text.length === 0) return; - const commandAction = promptActionCommandFromAction(action); - - 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, - ]); - } - return; - } - - if (promptActionTextImmediatelyBeforeCursor(currentEditor, action.text)) { - focusAfterPromptAction(currentEditor); - return; - } - - const insertionRange = getPromptActionInsertionRange({ - editor: currentEditor, - action, - actions: promptActions ?? [], - triggers: promptActionTriggers(triggers, commandAction), - }); - if (insertionRange === null) { - focusAfterPromptAction(currentEditor); - return; - } - - if (commandAction) { - triggerKeyRef.current = ""; - dismissedTriggerRef.current = null; - isRestoringAppliedMentionRef.current = true; - setActiveTrigger(null); - setSelectedIndex(0); - onCommandQueryChange(null); - - try { - skipEditorChangeRef.current = true; - currentEditor - .chain() - .focus() - .deleteRange({ from: insertionRange.from, to: insertionRange.to }) - .insertContent([ - { - type: "mention", - attrs: { - resource: promptCommandResourceFromSuggestion({ - suggestion: commandAction.suggestion, - trigger: commandAction.trigger, - }), - serializedText: commandAction.serializedText, - }, - }, - ...(commandAction.trailingText - ? [{ type: "text", text: commandAction.trailingText }] - : []), - ]) - .run(); - } finally { - skipEditorChangeRef.current = false; - } - finishApply(currentEditor); - return; - } - - triggerKeyRef.current = ""; - dismissedTriggerRef.current = null; - setSelectedIndex(0); - currentEditor - .chain() - .focus() - .deleteRange({ from: insertionRange.from, to: insertionRange.to }) - .insertContent(action.text) - .run(); - finishApply(currentEditor); - }, - [ - finishApply, - focusAfterPromptAction, - onCommandQueryChange, - promptActions, - triggers, - ], - ); - - const getTextBeforeCursor = useCallback((): string | undefined => { - const currentValue = valueRef.current; - const currentEditor = editorRef.current; - if (!currentEditor) { - const trimmed = currentValue.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - const beforeCursor = currentEditor.state.doc - .textBetween(0, currentEditor.state.selection.from, "\n", "\n") - .trim(); - return beforeCursor.length > 0 ? beforeCursor : undefined; - }, []); - - useImperativeHandle( - promptBoxRef, - () => ({ - captureHeightForLayoutChange: capturePromptBoxHeight, - focusEnd, - insertTextAtCursor, - getTextBeforeCursor, - }), - [capturePromptBoxHeight, focusEnd, insertTextAtCursor, getTextBeforeCursor], - ); - - const canSubmit = - hasSubmittableInput && !isSubmitting && !submitDisabled && !isVoiceBusy; - const canModifierSubmit = - onModifierSubmit !== undefined && - !isSubmitting && - !submitDisabled && - !isVoiceBusy; - const showStop = Boolean(isRunning && onStop && !canSubmit && !isVoiceBusy); - const canStartVoiceInput = - voice !== undefined && voice.isSupported && !isSubmitting; - const showVoiceAsPrimaryAction = - isPointerCoarse && !hasSubmittableInput && canStartVoiceInput; - const handleVoicePointerDown = useCallback( - (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(); - }, - [isPointerCoarse], - ); - const startVoiceInput = useCallback(() => { - if (isPointerCoarse) { - const currentEditor = editorRef.current; - if (currentEditor && !currentEditor.isDestroyed) { - blurActiveKeyboardInputWithin(currentEditor.view.dom); - } - } - void voice?.start(); - }, [isPointerCoarse, voice]); - const effectiveSubmitTitle = isZenMode - ? submitTitle.replace(/^Submit\s+/, "") - : submitTitle; - - const emitAttachmentFiles = useCallback( - (files: File[]) => { - if (!onAttachFiles || files.length === 0) return; - void onAttachFiles(files); - }, - [onAttachFiles], - ); - - const resetZenModeAfterSubmit = useCallback(() => { - if (!resetZenModeOnSubmit || !isZenMode) return; - if (resolvedZenModeStorageKey) { - setIsZenMode(RESET); - return; - } - setIsZenMode(false); - }, [ - isZenMode, - resetZenModeOnSubmit, - resolvedZenModeStorageKey, - setIsZenMode, - ]); - - const submitPrompt = useCallback(() => { - if (!canSubmit) return; - onSubmit(); - resetZenModeAfterSubmit(); - }, [canSubmit, onSubmit, resetZenModeAfterSubmit]); - - const handleSubmitPointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.button !== 0) return; - const currentEditor = editorRef.current; - if ( - !currentEditor || - currentEditor.isDestroyed || - !currentEditor.isFocused - ) { - return; - } - - // Focus transfer happens before click. On iOS, moving focus from the - // editor to this button begins keyboard dismissal and resizes the app - // shell before the form can submit. Keep the editor focused; the click - // still owns the commit, while genuine outside focus dismisses normally. - event.preventDefault(); - }, - [], - ); - - // A no-argument built-in command (currently only `/compact`) is a complete - // action the moment it is selected, so applying it with Enter should also - // submit instead of leaving the pill parked for a second Enter. The submit is - // deferred to this effect — keyed on the flag — so `onSubmit` runs after the - // applied command mention has propagated into the parent draft (applying the - // pill updates the draft on the next render, not synchronously). - const [pendingCommandSubmit, setPendingCommandSubmit] = useState(false); - useEffect(() => { - if (!pendingCommandSubmit) return; - setPendingCommandSubmit(false); - submitPrompt(); - }, [pendingCommandSubmit, submitPrompt]); - - const submitModifierPrompt = useCallback(() => { - if (!canModifierSubmit || !onModifierSubmit) return; - onModifierSubmit(); - resetZenModeAfterSubmit(); - }, [canModifierSubmit, onModifierSubmit, resetZenModeAfterSubmit]); - - const applyHistoryDraft = useCallback( - (draft: PromptDraftState) => { - if (!history) { - return; - } - - history.onSelectEntry(draft); - requestAnimationFrame(() => { - const currentEditor = editorRef.current; - if (!currentEditor || currentEditor.isDestroyed) { - return; - } - - focusEditorAtEnd(currentEditor); - syncTriggerState(currentEditor); - scheduleRevealEditorSelection(); - }); - }, - [history, scheduleRevealEditorSelection, syncTriggerState], - ); - - const focusEditorAfterSizeChange = useCallback(() => { - // Size changes on mobile web are presentation-only. Keeping focus where it - // is prevents the soft keyboard from covering the thread after a tap. - if (isPointerCoarse) return; - requestAnimationFrame(() => { - const currentEditor = editorRef.current; - if (!currentEditor || currentEditor.isDestroyed) return; - - currentEditor.commands.focus(); - scheduleRevealEditorSelection(); - }); - }, [isPointerCoarse, scheduleRevealEditorSelection]); - - const exitZenMode = useCallback(() => { - capturePromptBoxHeight(); - if (!isZenMode) return; - setIsZenMode(false); - focusEditorAfterSizeChange(); - }, [ - capturePromptBoxHeight, - focusEditorAfterSizeChange, - isZenMode, - setIsZenMode, - ]); - - const enterZenMode = useCallback(() => { - capturePromptBoxHeight(); - // Mobile follow-up composers expand by focus, not a manual size control. - if (compact) return; - if (isZenMode) return; - setIsZenMode(true); - focusEditorAfterSizeChange(); - }, [ - capturePromptBoxHeight, - focusEditorAfterSizeChange, - isZenMode, - compact, - setIsZenMode, - ]); - - const handleAttachmentInputChange = useCallback( - (event: ChangeEvent) => { - const fileList = event.target.files; - if (!fileList || fileList.length === 0) return; - emitAttachmentFiles(Array.from(fileList)); - event.target.value = ""; - }, - [emitAttachmentFiles], - ); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - submitPrompt(); - }; - - const handlePromptBoxMouseDown = useCallback( - (event: PromptBoxMouseDownEvent) => { - if (!isPromptBoxChromeTarget(event.target)) return; - - const currentEditor = editorRef.current; - if (!currentEditor || currentEditor.isDestroyed) return; - - event.preventDefault(); - focusEditorAtEnd(currentEditor); - scheduleRevealEditorSelection(); - }, - [scheduleRevealEditorSelection], - ); - - const handleEditorKeyDown = useCallback( - (event: KeyboardEvent, isOriginalIPadHardwareEnter = false): boolean => { - // An IME keystroke must reach neither an app chord nor a submit. The - // WeakSet carries the iPadOS hook's decision, because that hook runs - // before ProseMirror's own post-composition safeguard. - if ( - event.isComposing || - event.keyCode === 229 || - postCompositionKeyDownEvents.has(event) - ) { - return false; - } - // App keybindings win over the editor's own keymap. TipTap cancels the - // chords it knows (Mod+Shift+B for a blockquote, Mod+B, Mod+Shift+7/8 for - // lists), and the window listener skips a canceled event — so without - // this an app chord silently did nothing while the composer had focus. - if (dispatchAppCommandKey(event)) { - return true; - } - const canSubmitWithEnterKey = - !isPointerCoarse || isOriginalIPadHardwareEnter; - const currentEditor = editorRef.current; - const selection = currentEditor?.state.selection; - const hasCollapsedSelection = Boolean(selection?.empty); - const hasArrowNavigationModifier = - event.shiftKey || event.altKey || event.metaKey || event.ctrlKey; - const hasCursorAtEnd = - hasCollapsedSelection && - currentEditor !== null && - currentEditor !== undefined && - selection !== undefined && - selection.from >= currentEditor.state.doc.content.size - 1; - const activeHistoryEntry = - history && activeHistoryIndex !== null - ? history.entries[activeHistoryIndex] - : null; - const hasSelectedHistoryEntry = Boolean( - history && - activeHistoryEntry !== null && - activeHistoryEntry !== undefined && - arePromptDraftStatesEqual(history.currentDraft, activeHistoryEntry), - ); - const canNavigateHistory = - history !== undefined && - !hasArrowNavigationModifier && - hasCursorAtEnd && - (isPromptDraftEmpty(history.currentDraft) || hasSelectedHistoryEntry); - const canNavigateTypeahead = - showTypeaheadMenu && !hasArrowNavigationModifier && !canNavigateHistory; - - if (showTypeaheadMenu) { - if ( - event.key === "ArrowDown" && - canNavigateTypeahead && - activeSuggestions.length > 0 - ) { - event.preventDefault(); - if ( - activeTriggerKind === "command" && - !commandError && - selectedIndex >= activeSuggestions.length - 1 && - (commandHasMore || commandIsLoadingMore) - ) { - if (canLoadMoreCommands) { - loadMoreCommands(); - } - return true; - } - setSelectedIndex((prev) => (prev + 1) % activeSuggestions.length); - return true; - } - if ( - event.key === "ArrowUp" && - canNavigateTypeahead && - activeSuggestions.length > 0 - ) { - event.preventDefault(); - setSelectedIndex( - (prev) => - (prev + activeSuggestions.length - 1) % activeSuggestions.length, - ); - return true; - } - if ( - (event.key === "Enter" || event.key === "Tab") && - activeSuggestions.length > 0 - ) { - event.preventDefault(); - const selected = - activeSuggestions[selectedIndex] ?? activeSuggestions[0]; - if (selected) { - applyTrigger(selected); - // Built-in commands (e.g. `/compact`) take no arguments, so picking - // one with Enter both inserts the pill and submits. Tab still only - // inserts, and mention suggestions are unaffected. - if ( - event.key === "Enter" && - selected.kind === "command" && - selected.origin === "builtin" - ) { - setPendingCommandSubmit(true); - } - } - return true; - } - if (event.key === "Escape") { - event.preventDefault(); - triggerKeyRef.current = ""; - if (activeTrigger) { - // Escape dismisses the typed token span for both kinds — re-trigger - // stays suppressed while the caret remains inside `[from, to]`. - dismissedTriggerRef.current = { - start: activeTrigger.from, - end: activeTrigger.to, - hasLeftRange: false, - }; - } - setActiveTrigger(null); - onMentionQueryChange(null, null); - onCommandQueryChange(null); - return true; - } - } - - // Escape releases the composer so the keyboard can reach the rest of the - // app. Higher-priority Escape behavior still runs first: the typeahead - // menu above dismisses itself, and voice recording cancels from a window - // capture listener that stops the event before the editor sees it. A - // locked editor never reaches here — see the editor container below. - if (event.key === "Escape") { - blurPromptEditor(currentEditor); - return true; - } - - if (history) { - if ( - event.key === "ArrowUp" && - canNavigateHistory && - history.entries.length > 0 - ) { - event.preventDefault(); - const nextHistoryIndex = - activeHistoryIndex === null - ? 0 - : Math.min(activeHistoryIndex + 1, history.entries.length - 1); - if (activeHistoryIndex === null) { - setTemporaryHistoryDraft(history.currentDraft); - } - setActiveHistoryIndex(nextHistoryIndex); - const nextDraft = history.entries[nextHistoryIndex]; - setRecalledHistoryDraft(nextDraft); - applyHistoryDraft(nextDraft); - return true; - } - - if ( - event.key === "ArrowDown" && - canNavigateHistory && - activeHistoryIndex !== null - ) { - event.preventDefault(); - if (activeHistoryIndex === 0) { - if (temporaryHistoryDraft) { - applyHistoryDraft(temporaryHistoryDraft); - } - resetHistorySession(); - return true; - } - - const nextHistoryIndex = activeHistoryIndex - 1; - setActiveHistoryIndex(nextHistoryIndex); - const nextDraft = history.entries[nextHistoryIndex]; - setRecalledHistoryDraft(nextDraft); - applyHistoryDraft(nextDraft); - return true; - } - } - - const isModifierSubmitKey = - event.key === "Enter" && - event.metaKey && - !event.shiftKey && - !event.altKey && - !event.ctrlKey; - if (isModifierSubmitKey && onModifierSubmit) { - event.preventDefault(); - submitModifierPrompt(); - return true; - } - - const isBlockquoteExitKey = - event.key === "Enter" && - event.shiftKey && - !event.metaKey && - !event.altKey && - !event.ctrlKey; - if ( - isBlockquoteExitKey && - currentEditor && - applyPromptListNewline(currentEditor) - ) { - event.preventDefault(); - return true; - } - - if ( - isBlockquoteExitKey && - currentEditor && - (insertParagraphBeforeBlockquote(currentEditor) || - exitTrailingBlockquoteBreak(currentEditor)) - ) { - event.preventDefault(); - return true; - } - - const isPromptNewlineKey = - event.key === "Enter" && - !event.metaKey && - !event.altKey && - !event.ctrlKey && - (event.shiftKey || isZenMode || !canSubmitWithEnterKey); - if (isPromptNewlineKey && currentEditor && exitHeading(currentEditor)) { - event.preventDefault(); - return true; - } - - if ( - isPromptNewlineKey && - currentEditor && - applyPromptParagraphNewline(currentEditor) - ) { - event.preventDefault(); - return true; - } - - if (isZenMode || !canSubmitWithEnterKey) return false; - const isSubmitKey = event.key === "Enter" && !event.shiftKey; - - if (!isSubmitKey) return false; - event.preventDefault(); - submitPrompt(); - return true; - }, - [ - activeHistoryIndex, - activeSuggestions, - activeTrigger, - activeTriggerKind, - applyHistoryDraft, - applyTrigger, - canLoadMoreCommands, - commandError, - commandHasMore, - commandIsLoadingMore, - dispatchAppCommandKey, - history, - isPointerCoarse, - isZenMode, - loadMoreCommands, - onCommandQueryChange, - onMentionQueryChange, - onModifierSubmit, - postCompositionKeyDownEvents, - resetHistorySession, - selectedIndex, - setPendingCommandSubmit, - showTypeaheadMenu, - submitModifierPrompt, - submitPrompt, - temporaryHistoryDraft, - ], - ); - - useLayoutEffect(() => { - handleEditorKeyDownRef.current = handleEditorKeyDown; - }, [handleEditorKeyDown]); - - // Capture phase + stopPropagation so Escape cancels the recording and wins - // over the composer's own Escape-to-dismiss (which would otherwise hide the - // whole box), instead of leaking to the collapsed editor. - useEffect(() => { - if (!showVoiceActionGroup || !voice) return; - const cancelVoice = voice.cancel; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - cancelVoice(); - }; - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [showVoiceActionGroup, voice]); - + const isCompact = compact?.isCompact === true; return ( -
{ - if (!onAttachFiles) return; - event.preventDefault(); - }} - onDrop={(event) => { - if (!onAttachFiles) return; - event.preventDefault(); - if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) - return; - emitAttachmentFiles(Array.from(event.dataTransfer.files)); - }} - className={cn( - "group/promptbox relative w-full rounded-xl border border-border bg-background shadow-lift", - "transition-[border-radius] duration-[180ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none", - showVoiceActionGroup && "rounded-3xl", - showCompactLayout && "overflow-hidden", - // Zen toggles only the *height* of the box; the inset padding stays - // identical so the placeholder/text doesn't jump when toggling. - // `flex flex-col` lets the editor's `flex-1` fill the dvh height. - showZenLayout && "flex flex-col", - showZenLayout && ZEN_MODE_HEIGHT_CLASS[zenModeLayout], - className, - )} + data-promptbox-loading="" + aria-busy="true" + className={`relative w-full rounded-xl border border-border bg-background shadow-lift ${className ?? ""}`} > - + {header && !isCompact ? ( +
{header}
+ ) : null}
-
- {header && !showCompactLayout ? ( - // Left padding matches the editor's so the header content aligns - // with the placeholder column in both normal and zen modes (editor - // shifts from px-4 to px-6 when entering zen). Right padding leaves - // room for the zen-mode toggle button in the top-right corner. Zen - // mode also gets more top room since the card fills the viewport. -
- {header} -
- ) : null} -
- {!showCompactLayout ? ( - <> -
- -
-
- {isZenMode ? ( - - ) : null} - {!isZenMode && !compact ? ( - - ) : null} -
- - ) : null} -
- - { - if (event.key !== "Escape") return; - if (editor === null || editor.isEditable) return; - event.preventDefault(); - blurPromptEditor(editor); - }} - data-promptbox-editor-content="" - data-promptbox-compact-content={ - showCompactLayout ? "" : undefined - } - className={cn( - "h-full min-h-full", - showCompactLayout && "flex items-center", - "[&_.ProseMirror]:min-h-full [&_.ProseMirror]:leading-[1.7] [&_.ProseMirror]:outline-none", - "[&_.ProseMirror_p]:m-0", - "[&_.ProseMirror_blockquote]:my-1 [&_.ProseMirror_blockquote]:border-l-2 [&_.ProseMirror_blockquote]:border-surface-selected-border [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_blockquote]:text-muted-foreground", - // Markdown formatting styles (mirrors what the timeline renders). - "[&_.ProseMirror_h1]:my-1 [&_.ProseMirror_h1]:text-lg [&_.ProseMirror_h1]:font-semibold", - "[&_.ProseMirror_h2]:my-1 [&_.ProseMirror_h2]:text-base [&_.ProseMirror_h2]:font-semibold", - "[&_.ProseMirror_h3]:my-1 [&_.ProseMirror_h3]:text-sm [&_.ProseMirror_h3]:font-semibold", - "[&_.ProseMirror_h4]:my-1 [&_.ProseMirror_h4]:text-sm [&_.ProseMirror_h4]:font-semibold [&_.ProseMirror_h5]:font-semibold [&_.ProseMirror_h6]:font-semibold", - "[&_.ProseMirror_ul]:my-1 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-5", - "[&_.ProseMirror_ol]:my-1 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-5", - "[&_.ProseMirror_li]:my-0.5 [&_.ProseMirror_li>p]:m-0", - "[&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:bg-surface-selected [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.9em]", - "[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none", - "[&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left", - "[&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0", - "[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-subtle-foreground", - "[&_.ProseMirror_p.is-editor-empty:first-child::before]:font-light", - "[&_.ProseMirror_p.is-editor-empty:first-child::before]:opacity-70", - )} - /> - -
-
- - {showTypeaheadMenu ? ( -
- -
- ) : null} - - {!showCompactLayout ? ( - <> -
- - - {attachmentError ? ( -
- {attachmentError} -
- ) : null} -
- - ) : null} - - -
- {!showCompactLayout ? ( -
- attachmentInputRef.current?.click() - : undefined - } - onAction={applyPromptAction} - pluginItems={ - suppressPluginComposerCustomizations - ? [] - : pluginPlusMenuItems - } - /> - {footerStart} -
- ) : null} -
- {!showCompactLayout ? ( - <> - {!suppressPluginComposerCustomizations ? ( - - ) : null} - {voice && - !showVoiceActionGroup && - !showVoiceAsPrimaryAction ? ( - - ) : null} - - ) : null} -
- {showStop ? ( - - ) : showVoiceAsPrimaryAction ? ( - - ) : ( - - )} -
-
-
-
-
-
-
-
- {voice && showVoiceActionGroup ? ( - - ) : null} -
+ {placeholder}
-
+ ); } + +export function PromptBoxInternal(props: PromptBoxInternalProps) { + return ( + }> + + + ); +} + +export { + INERT_TYPEAHEAD_COMMAND_CONFIG, + suppressPromptEditorAnchorActivation, +} from "./prompt-box-runtime"; +export type { + AttachmentsConfig, + HistoryConfig, + MentionMenuPlacement, + PromptBoxCompactConfig, + PromptBoxHandle, + PromptBoxInternalProps, + PromptBoxSubmissionConfig, + PromptBoxZenModeConfig, + PromptVoiceConfig, + PromptVoiceState, + TypeaheadCommandConfig, + TypeaheadConfig, + TypeaheadMentionConfig, +} from "./PromptBoxInternalImpl"; +export type { PromptBoxAction } from "./PromptBoxActionsMenu"; diff --git a/apps/app/src/components/promptbox/PromptBoxInternalImpl.tsx b/apps/app/src/components/promptbox/PromptBoxInternalImpl.tsx new file mode 100644 index 0000000000..09ebeef52b --- /dev/null +++ b/apps/app/src/components/promptbox/PromptBoxInternalImpl.tsx @@ -0,0 +1,3270 @@ +import { atom, useAtom } from "jotai"; +import { RESET, atomWithStorage } from "jotai/utils"; +import type { + PromptMentionCommandTrigger, + PromptTextMention, +} from "@bb/domain"; +import type { ComposerView } from "@bb/plugin-sdk"; +import type { Node as ProseMirrorNode, Slice } from "@tiptap/pm/model"; +import { TextSelection } from "@tiptap/pm/state"; +import { EditorContent, useEditor, type Editor } from "@tiptap/react"; +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FormEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, + type Ref, +} from "react"; +import { + orderCommandSuggestionsBySection, + type ActiveTrigger, + type CommandMenuState, + type ComposerCommandSuggestion, + type MentionMenuState, + type ProviderCommandSuggestion, + type PromptMentionSuggestion, + type TypeaheadMenuState, + type TypeaheadTrigger, +} from "@/components/promptbox/mentions/types"; +import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; +import { + useAppCommandKeyDispatch, + useAppCommandShortcut, +} from "@/components/commands/AppCommandProvider"; +import { commandPillDismissedRangeEnd } from "@/components/promptbox/mentions/command-trigger"; +import { findActiveTrigger } from "@/components/promptbox/mentions/find-active-trigger"; +import { canLoadMoreCommandResults } from "@/components/promptbox/mentions/mention-menu-scroll"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { + PluginComposerActions, + usePluginComposerPlusMenuContributions, +} from "@/components/plugin/PluginComposerActions"; +import { + PluginComposerViewProvider, + useOptionalPluginComposerView, + usePluginComposerHost, + usePluginComposerViewModel, +} from "@/components/plugin/plugin-composer-host"; +import { composerCustomizationsForScope } from "@/components/plugin/composer-customizations"; +import { useComposerInputLock } from "@/lib/plugin-sdk-hooks"; +import { usePluginSlots } from "@/lib/plugin-slots"; +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 { blurActiveKeyboardInputWithin } from "@bb/shared-ui/overlay-trigger"; +import { createJsonLocalStorage } from "@/lib/browser-storage"; +import { + DEFAULT_PLUGIN_MENTION_TRIGGER, + type PluginMentionTrigger, +} from "@/lib/plugin-mention-triggers"; +import { useRichTextEditingPreference } from "@/lib/rich-text-editing-preference"; +import { + arePromptDraftStatesEqual, + isPromptDraftEmpty, + type PromptDraftAttachment, + type PromptDraftState, +} from "@/lib/prompt-draft"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { AttachmentPreview } from "./AttachmentPreview"; +import { + INERT_TYPEAHEAD_COMMAND_CONFIG, + suppressPromptEditorAnchorActivation, +} from "./prompt-box-runtime"; +import { VoiceRecordingBar } from "./VoiceRecordingBar"; +import { + PromptBoxActionsMenu, + type PromptBoxAction, +} from "./PromptBoxActionsMenu"; +import { + PromptMentionLinkContext, + type PromptMentionLinkResolver, +} from "./editor/prompt-mention-link"; +import { + refreshPromptDecorations, + type PromptDecorationSource, + type PromptDraftObserver, +} from "./editor/prompt-decoration-extension"; +import type { ComposerTextEffectSource } from "@/lib/composer-text-effects"; +import { promptEditorExtensions } from "./editor/prompt-editor-extensions"; +import { + promptCommandResourceFromSuggestion, + promptEditorClipboardTextFromSlice, + promptEditorContentFromValue, + promptEditorInlineContentFromValue, + promptEditorValueFromDoc, + promptEditorValueFromSlice, + parsePromptEditorMentionAttrs, + promptMentionResourceFromSuggestion, + type PromptEditorValue, +} from "./editor/prompt-editor-serialization"; +import { + exitTrailingBlockquoteBreak, + insertParagraphBeforeBlockquote, + removeEmptyBlockquotes, +} from "./editor/prompt-editor-blockquote"; +import { exitHeading } from "./editor/prompt-editor-heading"; +import { applyPromptListNewline } from "./editor/prompt-editor-list"; +import { applyPromptParagraphNewline } from "./editor/prompt-editor-paragraph"; +import { MentionMenu, type TypeaheadSuggestion } from "./mentions/MentionMenu"; +import { parsePromptMentionClipboardElement } from "./mentions/prompt-mention-clipboard"; + +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", + "ASIDE", + "BLOCKQUOTE", + "DIV", + "DD", + "DL", + "DT", + "FIGCAPTION", + "FIGURE", + "FOOTER", + "FORM", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEADER", + "HR", + "MAIN", + "NAV", + "P", + "SECTION", + "TABLE", + "TBODY", + "TD", + "TFOOT", + "TH", + "THEAD", + "TR", +]); +const RICH_PASTE_IGNORED_TAGS = new Set([ + "HEAD", + "LINK", + "META", + "NOSCRIPT", + "SCRIPT", + "STYLE", + "TITLE", +]); + +function hasWhitespaceAfterPosition( + doc: ProseMirrorNode, + position: number, +): boolean { + const nextNode = doc.resolve(position).nodeAfter; + if (!nextNode) { + return false; + } + if (nextNode.isText) { + return /^\s/u.test(nextNode.text ?? ""); + } + return nextNode.type.name === "hardBreak"; +} + +type ZenModeLayout = "thread" | "root-compose"; + +const ZEN_MODE_STORAGE_KEY: Record = { + thread: "bb.promptbox.zen-mode.thread", + "root-compose": "bb.promptbox.zen-mode.root-compose", +}; + +const ZEN_MODE_HEIGHT_CLASS: Record = { + thread: "h-[50dvh]", + "root-compose": "h-[70dvh]", +}; + +const PROMPTBOX_MAX_HEIGHT_BY_LAYOUT: Record = { + thread: "50dvh", + "root-compose": "70dvh", +}; + +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 interface PromptBoxSubmissionConfig { + isSubmitting?: boolean; + disabled?: boolean; + title?: string; + isRunning?: boolean; + onStop?: () => void; + onModifierSubmit?: () => void; +} + +/** + * The `@`-mention half of {@link TypeaheadConfig}. Unchanged from the prior + * `MentionsConfig` surface other than living under `typeahead.mention`. + */ +export interface TypeaheadMentionConfig { + /** Mention trigger characters to watch. Defaults to `@`. */ + triggers?: readonly PluginMentionTrigger[]; + suggestions: readonly PromptMentionSuggestion[]; + isLoading: boolean; + isError: boolean; + /** Called whenever the active mention query changes; null when no mention is active. */ + onQueryChange: ( + query: string | null, + trigger: PluginMentionTrigger | null, + ) => void; + /** + * Resolves the click action for an inserted mention pill (navigate to a + * thread, open a file preview). Omit to render pills as non-interactive + * text; returns null per-resource when that mention isn't openable here. + */ + resolveLink?: PromptMentionLinkResolver; +} + +/** + * The command-typeahead half of {@link TypeaheadConfig}. `trigger` is the + * provider's command char or `null` when the provider has no command + * surface — in which case the composer never activates a command trigger and + * the rest of this config is inert. + * + * Hosts wire `suggestions` / `isLoading` / `isError` from + * `useCommandSuggestions`; `onQueryChange` feeds that hook the text typed + * after the trigger (`null` when no command trigger is active). + */ +export interface TypeaheadCommandConfig { + trigger: PromptMentionCommandTrigger | null; + suggestions: readonly ComposerCommandSuggestion[]; + isLoading: boolean; + isError: boolean; + hasMore: boolean; + isLoadingMore: boolean; + loadMore: () => void; + /** Called whenever the active command query changes; null when no command trigger is active. */ + onQueryChange: (query: string | null) => void; +} + +/** + * Generalized composer typeahead config covering both trigger kinds. `@` + * mentions are always available; commands are active only when + * `command.trigger` is non-null. Hosts supply both halves; the composer picks + * the active trigger from the caret and renders the matching data source. + */ +export interface TypeaheadConfig { + mention: TypeaheadMentionConfig; + command: TypeaheadCommandConfig; +} + +/** + * Inert command half: no trigger, no suggestions, no-op query change. Hosts use + * it as `typeahead.command` until they wire real command data from + * `useCommandSuggestions`. With `trigger: null` the composer never activates a + * command trigger, so the rest of the fields are never read. + */ +export { INERT_TYPEAHEAD_COMMAND_CONFIG }; + +export interface AttachmentsConfig { + items?: PromptDraftAttachment[]; + isAttaching?: boolean; + error?: string | null; + onAttachFiles?: (files: File[]) => void | Promise; + onRemove?: (path: string) => void; + projectId?: string; +} + +export interface PromptBoxZenModeConfig { + layout?: ZenModeLayout; + storageKey?: string | null; + resetKey?: string | number; + resetOnSubmit?: boolean; +} + +export interface PromptBoxCompactConfig { + isCompact: boolean; + placeholder?: string; +} + +export interface HistoryConfig { + currentDraft: PromptDraftState; + entries: readonly PromptDraftState[]; + onSelectEntry: (draft: PromptDraftState) => void; + resetKey?: string | number; +} + +export type PromptVoiceState = "idle" | "recording" | "transcribing" | "error"; + +export interface PromptVoiceConfig { + state: PromptVoiceState; + isSupported: boolean; + stream: MediaStream | null; + start: () => void | Promise; + stop: () => void; + cancel: () => void; +} + +export interface PromptBoxHandle { + /** Focus the editor and move the caret to the end. */ + focusEnd: () => void; + /** Capture the current card height before a controlled layout change. */ + captureHeightForLayoutChange: () => void; + /** Insert text at the editor's current cursor position, with smart spacing. */ + insertTextAtCursor: (text: string) => void; + /** Return the trimmed text before the cursor, used as voice transcript context. */ + getTextBeforeCursor: () => string | undefined; +} + +export type { PromptBoxAction } from "./PromptBoxActionsMenu"; + +export type MentionMenuPlacement = "top" | "bottom"; + +export interface PromptBoxInternalProps { + id?: string; + value: string; + mentionRanges: readonly PromptTextMention[]; + onChange: (value: string, mentionRanges: PromptTextMention[]) => void; + onSubmit: () => void; + placeholder?: string; + className?: string; + /** Plugin-owned whole-draft paint sources, in deterministic composition order. */ + textEffects?: readonly ComposerTextEffectSource[]; + /** Publishes the editor-owned layout to the concrete composer shell. */ + onComposerLayoutChange?: (layout: ComposerView["layout"]) => void; + /** Content rendered inside the prompt box card, above the text area. Use + * for prominent context that should be impossible to miss — e.g. a + * "Reusing existing worktree" banner when env mode is set to reuse. */ + header?: ReactNode; + footerStart?: ReactNode; + submission?: PromptBoxSubmissionConfig; + /** + * Minimum textarea height in pixels. Defaults to PROMPTBOX_MIN_HEIGHT. + * Callers may pass a smaller value to make room for siblings that grow + * above the textarea (see FollowUpPromptBox's elastic compensation for + * the context banner stack) — total prompt-area height stays constant. + */ + minHeight?: number; + typeahead: TypeaheadConfig; + /** + * Where the typeahead menu floats relative to the prompt box. + * "top" floats it above (used by FollowUp where the prompt sits at the + * bottom of the thread), "bottom" floats it below (used by NewThread + * where the prompt sits at the top of the project view). + */ + mentionMenuPlacement: MentionMenuPlacement; + attachments?: AttachmentsConfig; + promptActions?: readonly PromptBoxAction[]; + /** Suppress plugin composer regions without unmounting the editor. */ + suppressPluginComposerCustomizations?: boolean; + zenMode?: PromptBoxZenModeConfig; + /** Optional one-line presentation for unfocused mobile follow-up composers. */ + compact?: PromptBoxCompactConfig; + /** Compact placeholder used when a follow-up composer is narrowed by its container. */ + containerCompactPlaceholder?: string; + /** + * Changing this after captureHeightForLayoutChange() animates a layout + * change that is driven outside this component, such as a container query. + */ + heightAnimationKey?: string | number; + history?: HistoryConfig; + /** When omitted, the mic button is hidden. Wrappers wire this via usePromptVoice. */ + voice?: PromptVoiceConfig; + promptBoxRef?: Ref; + /** + * Changing this re-focuses the editor caret to the end. Used by explicit + * draft-restore actions (e.g. editing a queued message) so the user can type + * immediately. Unlike the scope autofocus it fires even on coarse pointers, + * since it follows a deliberate click. + */ + focusEndKey?: string | number; +} + +interface DismissedTriggerRange { + start: number; + end: number; + hasLeftRange: boolean; +} + +interface PromptEditorValueKey { + text: string; + mentions: readonly PromptTextMention[]; +} + +const DEFAULT_TYPEAHEAD_MENTION_TRIGGERS = [ + DEFAULT_PLUGIN_MENTION_TRIGGER, +] as const satisfies readonly PluginMentionTrigger[]; + +interface PromptEditorSelectionRevealArgs { + editor: Editor; + scrollContainer: HTMLElement; +} + +interface ParsedRichClipboardValue { + hasMentions: boolean; + value: PromptEditorValue; +} + +type ZenModeUpdate = + | boolean + | typeof RESET + | ((previous: boolean) => boolean | typeof RESET); + +type PromptBoxMouseDownEvent = ReactMouseEvent; + +interface PromptActionInsertionRange { + from: number; + 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(","); + +function createTransientZenModeAtom() { + const baseAtom = atom(false); + return atom( + (get) => get(baseAtom), + (get, set, update: ZenModeUpdate) => { + const currentValue = get(baseAtom); + const nextValue = + typeof update === "function" ? update(currentValue) : update; + + set(baseAtom, nextValue === RESET ? false : nextValue); + }, + ); +} + +function promptEditorValueKey(value: PromptEditorValueKey): string { + return JSON.stringify(value); +} + +function normalizePastedPlainText(text: string): string { + return text.replace(/\r\n?/gu, "\n"); +} + +function promptActionCommandMentionsFromText( + text: string, + actions: readonly PromptBoxAction[] | undefined, +): PromptTextMention[] { + const mentions: PromptTextMention[] = []; + + for (const action of actions ?? []) { + const commandAction = promptActionCommandFromAction(action); + if (commandAction === null) { + continue; + } + + let searchStart = 0; + while (searchStart < text.length) { + const start = text.indexOf(commandAction.serializedText, searchStart); + if (start === -1) { + break; + } + + const end = start + commandAction.serializedText.length; + const before = start === 0 ? "" : text[start - 1]!; + const after = end >= text.length ? "" : text[end]!; + const hasTokenBoundaryBefore = before === "" || /\s/u.test(before); + const hasTokenBoundaryAfter = after === "" || /\s/u.test(after); + + if (hasTokenBoundaryBefore && hasTokenBoundaryAfter) { + mentions.push({ + start, + end, + resource: promptCommandResourceFromSuggestion({ + suggestion: commandAction.suggestion, + trigger: commandAction.trigger, + }), + }); + } + + searchStart = end; + } + } + + return mentions.sort( + (left, right) => left.start - right.start || left.end - right.end, + ); +} + +function mergePromptTextMentions( + baseMentions: readonly PromptTextMention[], + additionalMentions: readonly PromptTextMention[], +): PromptTextMention[] { + const merged = [...baseMentions].sort( + (left, right) => left.start - right.start || left.end - right.end, + ); + + for (const additionalMention of additionalMentions) { + const overlapsExisting = merged.some( + (mention) => + additionalMention.start < mention.end && + additionalMention.end > mention.start, + ); + if (!overlapsExisting) { + merged.push(additionalMention); + } + } + + return merged.sort( + (left, right) => left.start - right.start || left.end - right.end, + ); +} + +function withPromptActionCommandMentions( + value: PromptEditorValue, + promptActions: readonly PromptBoxAction[] | undefined, +): PromptEditorValue { + const promptActionMentions = promptActionCommandMentionsFromText( + value.text, + promptActions, + ); + if (promptActionMentions.length === 0) { + return value; + } + + return { + ...value, + mentions: mergePromptTextMentions(value.mentions, promptActionMentions), + }; +} + +function promptEditorValueFromPlainText( + text: string, + promptActions?: readonly PromptBoxAction[], +): PromptEditorValue { + const normalizedText = normalizePastedPlainText(text); + return withPromptActionCommandMentions( + { + text: normalizedText, + mentions: [], + }, + promptActions, + ); +} + +function promptEditorSliceHasBlockquote(slice: Slice): boolean { + let hasBlockquote = false; + slice.content.descendants((node) => { + if (node.type.name === "blockquote") { + hasBlockquote = true; + return false; + } + return true; + }); + return hasBlockquote; +} + +function plainTextHasQuoteLine(text: string): boolean { + return normalizePastedPlainText(text) + .split("\n") + .some((line) => line === ">" || line.startsWith("> ")); +} + +function trimTrailingPromptNewlines( + value: PromptEditorValue, +): PromptEditorValue { + const text = value.text.replace(/\n+$/u, ""); + if (text.length === value.text.length) { + return value; + } + + return { + text, + mentions: value.mentions.filter((mention) => mention.end <= text.length), + }; +} + +function promptEditorValueFromRichHtml(html: string): ParsedRichClipboardValue { + const document = new DOMParser().parseFromString(html, "text/html"); + let text = ""; + let hasMentions = false; + const mentions: PromptTextMention[] = []; + + const appendNewline = () => { + text = text.replace(/[ \t]+$/u, ""); + if (text.length > 0 && !text.endsWith("\n")) { + text += "\n"; + } + }; + + const appendCollapsedText = (rawText: string) => { + const collapsedText = rawText.replace(/\s+/gu, " "); + if (collapsedText.trim().length === 0) { + if (text.length > 0 && !/[\s]$/u.test(text)) { + text += " "; + } + return; + } + text += collapsedText; + }; + + const appendClipboardMention = (element: Element): boolean => { + const payload = parsePromptMentionClipboardElement({ element }); + if (!payload) { + return false; + } + + const start = text.length; + text += payload.serializedText; + mentions.push({ + start, + end: text.length, + resource: payload.resource, + }); + hasMentions = true; + return true; + }; + + const visitChildren = (node: Node, preserveWhitespace: boolean) => { + for (const childNode of node.childNodes) { + visitNode(childNode, preserveWhitespace); + } + }; + + const visitNode = (node: Node, preserveWhitespace: boolean) => { + if (node.nodeType === Node.TEXT_NODE) { + const rawText = node.textContent ?? ""; + if (preserveWhitespace) { + text += normalizePastedPlainText(rawText); + return; + } + appendCollapsedText(rawText); + return; + } + + if (!(node instanceof Element)) { + visitChildren(node, preserveWhitespace); + return; + } + + const tagName = node.tagName.toUpperCase(); + if (RICH_PASTE_IGNORED_TAGS.has(tagName)) { + return; + } + if (appendClipboardMention(node)) { + return; + } + if (tagName === "BR") { + appendNewline(); + return; + } + if (tagName === "PRE") { + appendNewline(); + text += normalizePastedPlainText(node.textContent ?? ""); + appendNewline(); + return; + } + if (tagName === "LI") { + appendNewline(); + text += "- "; + visitChildren(node, preserveWhitespace); + appendNewline(); + return; + } + if (RICH_PASTE_BLOCK_TAGS.has(tagName)) { + appendNewline(); + visitChildren(node, preserveWhitespace); + appendNewline(); + return; + } + + visitChildren(node, preserveWhitespace); + }; + + visitChildren(document.body, false); + + if (hasMentions) { + const trimmedText = text.replace(/\n+$/u, ""); + return { + hasMentions, + value: { + text: trimmedText, + mentions: mentions.filter( + (mention) => + mention.start >= 0 && + mention.end > mention.start && + mention.end <= trimmedText.length, + ), + }, + }; + } + + return { + hasMentions, + value: { + text: text + .replace(/[ \t]+\n/gu, "\n") + .replace(/\n{3,}/gu, "\n\n") + .replace(/^\n+/u, "") + .replace(/\n+$/u, ""), + mentions: [], + }, + }; +} + +function promptEditorValueFromClipboardPaste( + clipboardData: DataTransfer | null, + promptActions?: readonly PromptBoxAction[], +): PromptEditorValue | null { + const html = clipboardData?.getData("text/html") ?? ""; + const hasHtml = html.trim().length > 0; + if (hasHtml) { + const richValue = promptEditorValueFromRichHtml(html); + if (richValue.hasMentions) { + return withPromptActionCommandMentions(richValue.value, promptActions); + } + } + + const plainText = clipboardData?.getData("text/plain") ?? ""; + if (plainText.length > 0) { + return promptEditorValueFromPlainText(plainText, promptActions); + } + + if (!hasHtml) { + return null; + } + + return promptEditorValueFromRichHtml(html).value; +} + +function runAfterClipboardCut(callback: () => void): void { + if (typeof queueMicrotask === "function") { + queueMicrotask(callback); + return; + } + + setTimeout(callback, 0); +} + +function revealPromptEditorSelection({ + editor, + scrollContainer, +}: PromptEditorSelectionRevealArgs): void { + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + if (scrollContainerRect.height <= 0) return; + + let selectionRect: ReturnType; + try { + selectionRect = editor.view.coordsAtPos(editor.state.selection.to); + } catch { + return; + } + + const topOverflow = + selectionRect.top - + scrollContainerRect.top - + PROMPTBOX_SELECTION_REVEAL_MARGIN; + if (topOverflow < 0) { + scrollContainer.scrollTop = Math.max( + 0, + scrollContainer.scrollTop + topOverflow, + ); + return; + } + + const bottomOverflow = + selectionRect.bottom - + scrollContainerRect.bottom + + PROMPTBOX_SELECTION_REVEAL_MARGIN; + if (bottomOverflow > 0) { + scrollContainer.scrollTop += bottomOverflow; + } +} + +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, +): boolean { + if (!editor.state.selection.empty) { + return false; + } + + const before = editor.state.doc.textBetween( + 0, + editor.state.selection.from, + "\n", + "\n", + ); + 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[], +): boolean { + if (node.type.name !== "mention") { + return false; + } + const attrs = parsePromptEditorMentionAttrs(node.attrs); + if (!attrs || attrs.resource.kind !== "command") { + return false; + } + const resource = attrs.resource; + return actions.some((action) => { + const command = action.command; + if (!command) { + return false; + } + return ( + resource.trigger === command.trigger && + resource.name === command.name && + attrs.serializedText === promptActionCommandSerializedText(action) + ); + }); +} + +function findPromptActionTextSuffix( + text: string, + actions: readonly PromptBoxAction[], +): PromptBoxAction | null { + return ( + actions.find( + (action) => + !action.command && action.text.length > 0 && text.endsWith(action.text), + ) ?? null + ); +} + +function getPromptActionRangeImmediatelyBeforeCursor({ + editor, + actions, +}: { + editor: Editor; + actions: readonly PromptBoxAction[]; +}): PromptActionInsertionRange | null { + const selection = editor.state.selection; + if (!selection.empty) { + return null; + } + + const { $from } = selection; + const cursorOffset = $from.parentOffset; + const parentStart = $from.start(); + let searchOffset = cursorOffset; + + while (searchOffset > 0) { + const previous = $from.parent.childBefore(searchOffset); + const node = previous.node; + if (!node) { + return null; + } + const sizeBeforeSearchOffset = searchOffset - previous.offset; + if (node.isText) { + const textBeforeCursor = (node.text ?? "").slice( + 0, + sizeBeforeSearchOffset, + ); + const textAction = findPromptActionTextSuffix(textBeforeCursor, actions); + if (textAction) { + return { + from: + parentStart + + previous.offset + + textBeforeCursor.length - + textAction.text.length, + to: selection.from, + }; + } + if (/\S/u.test(textBeforeCursor)) { + return null; + } + searchOffset = previous.offset; + continue; + } + if ( + sizeBeforeSearchOffset === node.nodeSize && + isPromptActionCommandMention(node, actions) + ) { + return { + from: parentStart + previous.offset, + to: selection.from, + }; + } + return null; + } + + return null; +} + +function getPromptActionInsertionRange({ + editor, + action, + actions, + triggers, +}: { + editor: Editor; + action: PromptBoxAction; + actions: readonly PromptBoxAction[]; + triggers: readonly TypeaheadTrigger[]; +}): PromptActionInsertionRange | null { + const selection = editor.state.selection; + if (!selection.empty) { + return { from: selection.from, to: selection.to }; + } + + const previousPromptActionRange = getPromptActionRangeImmediatelyBeforeCursor( + { + editor, + actions, + }, + ); + if (previousPromptActionRange !== null) { + return previousPromptActionRange; + } + + const activeCommandTrigger = findActiveTrigger(editor, triggers); + const isActiveCommand = + activeCommandTrigger !== null && activeCommandTrigger.kind === "command"; + + if (action.kind === "skills") { + if ( + isActiveCommand && + activeCommandTrigger.char === action.text && + activeCommandTrigger.to === selection.from + ) { + return null; + } + return { from: selection.from, to: selection.to }; + } + + if (isActiveCommand && activeCommandTrigger.to === selection.from) { + return { + from: activeCommandTrigger.from, + to: activeCommandTrigger.to, + }; + } + + 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, +): readonly TypeaheadTrigger[] { + if (commandAction === null) { + return triggers; + } + if ( + triggers.some( + (trigger) => + trigger.kind === "command" && trigger.char === commandAction.trigger, + ) + ) { + return triggers; + } + return [ + ...triggers, + { kind: "command", char: commandAction.trigger }, + ] satisfies TypeaheadTrigger[]; +} + +export { suppressPromptEditorAnchorActivation }; + +// TipTap's `blur` command defers to the next animation frame, so blur the +// editor DOM directly and drop the caret with it. +function blurPromptEditor(editor: Editor | null | undefined): void { + editor?.view.dom.blur(); + window.getSelection()?.removeAllRanges(); +} + +function focusEditorAtEnd(editor: Editor): void { + const transaction = editor.state.tr + .setSelection(TextSelection.atEnd(editor.state.doc)) + .scrollIntoView(); + editor.view.dispatch(transaction); + editor.view.focus(); +} + +const SAFARI_POST_COMPOSITION_KEYDOWN_WINDOW_MS = 500; + +function isIPadOSWebKit(): boolean { + if (typeof navigator === "undefined") return false; + + const isAppleWebKit = + /Apple Computer/u.test(navigator.vendor) && + /\bAppleWebKit\//u.test(navigator.userAgent); + const isIPad = + navigator.platform === "iPad" || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 2); + return isAppleWebKit && isIPad; +} + +/** + * Holds the keydown events that the iPadOS hook refused as an IME candidate + * confirmation, so the normal key handler refuses them too. The set is keyed on + * the event object, so entries disappear with the events themselves. + */ +function usePostCompositionKeyDownEvents(): WeakSet { + const ref = useRef | null>(null); + ref.current ??= new WeakSet(); + return ref.current; +} + +function isIPadHardwareEnterCandidate(event: KeyboardEvent): boolean { + return ( + event.key === "Enter" && + (event.code === "Enter" || event.code === "NumpadEnter") + ); +} + +export function PromptBoxInternal({ + id, + value, + mentionRanges, + onChange, + onSubmit, + placeholder = "Ask anything. @ to mention files, folders, or sections", + className, + textEffects, + onComposerLayoutChange, + header, + footerStart, + submission = {}, + minHeight = PROMPTBOX_MIN_HEIGHT, + typeahead, + mentionMenuPlacement, + attachments: attachmentConfig = {}, + promptActions, + suppressPluginComposerCustomizations = false, + zenMode = {}, + compact, + containerCompactPlaceholder, + heightAnimationKey, + history, + voice, + promptBoxRef, + focusEndKey, +}: PromptBoxInternalProps) { + const focusComposerShortcut = useAppCommandShortcut("composer.focus"); + const { + isSubmitting = false, + disabled: submitDisabled = false, + title: submitTitle = "Submit (Enter)", + isRunning = false, + onStop, + onModifierSubmit, + } = submission; + const { + triggers: mentionTriggerChars = DEFAULT_TYPEAHEAD_MENTION_TRIGGERS, + suggestions: mentionSuggestions, + isLoading: mentionLoading, + isError: mentionError, + onQueryChange: onMentionQueryChange, + resolveLink: mentionResolveLink, + } = typeahead.mention; + const { + trigger: commandTriggerChar, + suggestions: commandSuggestions, + isLoading: commandLoading, + isError: commandError, + onQueryChange: onCommandQueryChange, + } = typeahead.command; + const { + items: attachments = [], + isAttaching = false, + error: attachmentError = null, + onAttachFiles, + onRemove: onRemoveAttachment, + projectId: attachmentProjectId, + } = attachmentConfig; + const { + layout: zenModeLayout = "thread", + storageKey: zenModeStorageKey, + resetKey: zenModeResetKey, + resetOnSubmit: resetZenModeOnSubmit = false, + } = zenMode; + const isPointerCoarse = usePointerCoarse(); + // Legacy iPads report an iPad platform; current iPadOS WebKit uses a + // desktop-like MacIntel platform with touch points distinguishing it from + // macOS. The value is stable for the lifetime of the page, so it does not + // need another media-query listener. + const isIPadOSWebKitDevice = useMemo(isIPadOSWebKit, []); + const editorEnterKeyHint = isPointerCoarse ? "enter" : "send"; + // Passive text autofocus opens the soft keyboard on coarse-pointer devices. + const shouldAvoidSoftKeyboardAutofocus = isPointerCoarse; + const formRef = useRef(null); + const heightAnimationFromRef = useRef(null); + const capturePromptBoxHeight = useCallback(() => { + const formElement = formRef.current; + heightAnimationFromRef.current = + formElement?.getBoundingClientRect().height ?? null; + }, []); + 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 editorRef = useRef(null); + const editorScrollContainerRef = useRef(null); + const revealSelectionFrameRef = useRef(null); + const promptActionFocusFrameRef = useRef(null); + const pendingFocusEndRef = useRef(false); + const attachmentInputRef = useRef(null); + const valueRef = useRef(value); + const mentionRangesRef = useRef(mentionRanges); + const placeholderRef = useRef(placeholder); + const skipEditorChangeRef = useRef(false); + const editorValueKeyRef = useRef(""); + const triggerKeyRef = useRef(""); + const handleEditorKeyDownRef = useRef< + (event: KeyboardEvent, isOriginalIPadHardwareEnter?: boolean) => boolean + >(() => false); + const compositionEndedAtRef = useRef(Number.NEGATIVE_INFINITY); + const postCompositionKeyDownEvents = usePostCompositionKeyDownEvents(); + const dispatchAppCommandKey = useAppCommandKeyDispatch(); + // The TipTap editor is created once; its `onUpdate`/`onSelectionUpdate`/click + // handlers close over the first `syncTriggerState`. `syncTriggerState` + // depends on the active trigger set, which changes when the thread's provider + // (command trigger) changes — so route those handlers through a ref kept + // pointed at the latest closure, mirroring `handleEditorKeyDownRef`. + const syncTriggerStateRef = useRef<(editor: Editor) => void>(() => {}); + const onAttachFilesRef = useRef(onAttachFiles); + const dismissedTriggerRef = useRef(null); + const isRestoringAppliedMentionRef = useRef(false); + const [activeTrigger, setActiveTrigger] = useState( + null, + ); + const [selectedIndex, setSelectedIndex] = useState(0); + const [expandedImageIndex, setExpandedImageIndex] = useState( + null, + ); + const [activeHistoryIndex, setActiveHistoryIndex] = useState( + null, + ); + const [temporaryHistoryDraft, setTemporaryHistoryDraft] = + useState(null); + const [recalledHistoryDraft, setRecalledHistoryDraft] = + useState(null); + const resolvedZenModeStorageKey = + zenModeStorageKey ?? ZEN_MODE_STORAGE_KEY[zenModeLayout]; + const zenModeAtom = useMemo( + () => + resolvedZenModeStorageKey + ? atomWithStorage( + resolvedZenModeStorageKey, + false, + createJsonLocalStorage(), + { + getOnInit: true, + }, + ) + : createTransientZenModeAtom(), + [resolvedZenModeStorageKey], + ); + const [isZenMode, setIsZenMode] = useAtom(zenModeAtom); + const isVoiceRecording = voice?.state === "recording"; + const isVoiceProcessing = voice?.state === "transcribing"; + const showVoiceActionGroup = isVoiceRecording || isVoiceProcessing; + const isVoiceBusy = showVoiceActionGroup; + // Zen styling is suppressed while the voice bar shows, since the box + // collapses to the pill instead. + const showZenLayout = isZenMode && !showVoiceActionGroup; + const showCompactLayout = + compact?.isCompact === true && !showVoiceActionGroup && !isZenMode; + const effectivePlaceholder = showCompactLayout + ? (compact.placeholder ?? placeholder) + : placeholder; + const pluginComposerHost = usePluginComposerHost(); + const { composerCustomizations } = usePluginSlots(); + const composerInputLocked = useComposerInputLock( + pluginComposerHost?.textEffectKey ?? null, + ); + const composerLayout = showCompactLayout + ? "compact" + : showZenLayout + ? "zen" + : "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; + const composerViewRef = useRef(composerView); + composerViewRef.current = composerView; + useEffect(() => { + onComposerLayoutChange?.(composerLayout); + }, [composerLayout, onComposerLayoutChange]); + const pluginRichTextContributions = useMemo(() => { + if (suppressPluginComposerCustomizations) { + return { + sources: [] as readonly PromptDecorationSource[], + observers: [] as readonly PromptDraftObserver[], + }; + } + + const sources: PromptDecorationSource[] = []; + const observers: PromptDraftObserver[] = []; + for (const customization of composerCustomizationsForScope( + composerCustomizations, + composerView.scope.kind, + )) { + const richText = customization.richText; + if (richText === undefined) continue; + const sourceId = `${customization.pluginId}/${customization.id}`; + if (richText.effects !== undefined && richText.effects.length > 0) { + sources.push({ + id: sourceId, + generation: customization.generation, + pluginId: customization.pluginId, + effects: richText.effects, + }); + } + if (richText.onDraftChange !== undefined) { + observers.push({ + id: sourceId, + getView: () => composerViewRef.current, + onDraftChange: richText.onDraftChange, + }); + } + } + for (const effectSource of textEffects ?? []) { + const className = effectSource.effect.className; + if (className.length === 0) continue; + sources.push({ + id: `plugin-imperative:${effectSource.pluginId}:${effectSource.order}`, + generation: effectSource.order, + pluginId: effectSource.pluginId, + effects: [ + { + id: "whole-draft", + className, + match: (text) => + text.length === 0 ? [] : [{ from: 0, to: text.length }], + }, + ], + }); + } + return { sources, observers }; + }, [ + composerCustomizations, + composerView.scope, + suppressPluginComposerCustomizations, + textEffects, + ]); + const pluginDecorationSourcesRef = useRef( + pluginRichTextContributions.sources, + ); + pluginDecorationSourcesRef.current = pluginRichTextContributions.sources; + const pluginDraftObserversRef = useRef(pluginRichTextContributions.observers); + pluginDraftObserversRef.current = pluginRichTextContributions.observers; + const pluginPlusMenuItems = + usePluginComposerPlusMenuContributions(composerView); + const focusScopeKey = history?.resetKey; + const onChangeRef = useRef(onChange); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + onAttachFilesRef.current = onAttachFiles; + }, [onAttachFiles]); + + const revealEditorSelection = useCallback(() => { + const currentEditor = editorRef.current; + const scrollContainer = editorScrollContainerRef.current; + if (!currentEditor || currentEditor.isDestroyed || !scrollContainer) return; + + revealPromptEditorSelection({ + editor: currentEditor, + scrollContainer, + }); + }, []); + + const scheduleRevealEditorSelection = useCallback(() => { + if (typeof requestAnimationFrame !== "function") { + revealEditorSelection(); + return; + } + + if (revealSelectionFrameRef.current !== null) { + cancelAnimationFrame(revealSelectionFrameRef.current); + } + + revealSelectionFrameRef.current = requestAnimationFrame(() => { + revealSelectionFrameRef.current = null; + revealEditorSelection(); + }); + }, [revealEditorSelection]); + + useEffect(() => { + return () => { + if (revealSelectionFrameRef.current === null) return; + cancelAnimationFrame(revealSelectionFrameRef.current); + }; + }, []); + + useEffect(() => { + return () => { + if (promptActionFocusFrameRef.current === null) return; + cancelAnimationFrame(promptActionFocusFrameRef.current); + }; + }, []); + + // Active trigger set: mention triggers are always watched; the provider's + // command trigger joins them when present. + const triggers = useMemo(() => { + const mentionTriggers = mentionTriggerChars.map((char) => ({ + char, + kind: "mention" as const, + })); + if (commandTriggerChar === null) { + return mentionTriggers; + } + return [...mentionTriggers, { char: commandTriggerChar, kind: "command" }]; + }, [commandTriggerChar, mentionTriggerChars]); + + // Fan the active query out to the matching data source and null the other, + // so switching from `@foo` to `/bar` (or vice versa) clears the stale query. + const dispatchTriggerQuery = useCallback( + (active: ActiveTrigger | null) => { + if (active?.kind === "mention") { + onMentionQueryChange(active.query, active.char); + onCommandQueryChange(null); + return; + } + if (active?.kind === "command") { + onCommandQueryChange(active.query); + onMentionQueryChange(null, null); + return; + } + onMentionQueryChange(null, null); + onCommandQueryChange(null); + }, + [onCommandQueryChange, onMentionQueryChange], + ); + + const syncTriggerState = useCallback( + (editor: Editor) => { + const caretPosition = editor.state.selection.from; + const dismissedTrigger = dismissedTriggerRef.current; + const isRestoringAppliedMention = + isRestoringAppliedMentionRef.current && dismissedTrigger !== null; + + if (dismissedTrigger && !isRestoringAppliedMention) { + const isWithinDismissedRange = + caretPosition >= dismissedTrigger.start && + caretPosition <= dismissedTrigger.end; + + if (!isWithinDismissedRange) { + dismissedTriggerRef.current = { + ...dismissedTrigger, + hasLeftRange: true, + }; + } else if (dismissedTrigger.hasLeftRange) { + dismissedTriggerRef.current = null; + } + } + + const shouldSuppressTrigger = Boolean( + dismissedTriggerRef.current && + !dismissedTriggerRef.current.hasLeftRange && + (isRestoringAppliedMention || + (caretPosition >= dismissedTriggerRef.current.start && + caretPosition <= dismissedTriggerRef.current.end)), + ); + + const nextTrigger = shouldSuppressTrigger + ? null + : findActiveTrigger(editor, triggers); + const nextKey = nextTrigger + ? `${nextTrigger.kind}:${nextTrigger.from}:${nextTrigger.to}:${nextTrigger.query}` + : ""; + if (nextKey !== triggerKeyRef.current) { + triggerKeyRef.current = nextKey; + setSelectedIndex(0); + } + setActiveTrigger(nextTrigger); + + dispatchTriggerQuery(nextTrigger); + }, + [dispatchTriggerQuery, triggers], + ); + + useEffect(() => { + syncTriggerStateRef.current = syncTriggerState; + }, [syncTriggerState]); + + // Markdown rich-text formatting (headings/lists/marks + their live input + // rules) is opt-in; the default-OFF preference keeps the prompt box plain + // text. Toggling rebuilds the editor (see the `[richTextEditing]` deps below) + // so the schema and input rules switch immediately. + const [richTextEditing] = useRichTextEditingPreference(); + const editorExtensions = useMemo( + () => + promptEditorExtensions({ + richTextEditing, + getPlaceholder: () => placeholderRef.current, + getDecorationSources: () => pluginDecorationSourcesRef.current, + getDraftObservers: () => pluginDraftObserversRef.current, + }), + [richTextEditing], + ); + + const editor = useEditor( + { + extensions: editorExtensions, + content: promptEditorContentFromValue( + { + text: value, + mentions: mentionRanges, + }, + { richTextMarkdown: richTextEditing }, + ), + immediatelyRender: false, + editorProps: { + attributes: { + "aria-label": effectivePlaceholder, + "data-placeholder": effectivePlaceholder, + ...(onModifierSubmit ? { "aria-keyshortcuts": "Meta+Enter" } : {}), + autocomplete: "off", + class: cn( + "min-h-full whitespace-pre-wrap break-words outline-none", + "placeholder:select-none placeholder:text-subtle-foreground", + ), + enterkeyhint: editorEnterKeyHint, + ...(id ? { id } : {}), + role: "textbox", + }, + clipboardTextSerializer: (slice, view) => + promptEditorClipboardTextFromSlice(slice, view.state.schema), + handleDOMEvents: { + auxclick: (_view, event) => { + return suppressPromptEditorAnchorActivation(event); + }, + blur: () => { + triggerKeyRef.current = ""; + if (dismissedTriggerRef.current) { + dismissedTriggerRef.current = { + ...dismissedTriggerRef.current, + hasLeftRange: true, + }; + } + setActiveTrigger(null); + onMentionQueryChange(null, null); + onCommandQueryChange(null); + return false; + }, + cut: () => { + runAfterClipboardCut(() => { + const currentEditor = editorRef.current; + if (!currentEditor || currentEditor.isDestroyed) return; + removeEmptyBlockquotes(currentEditor); + }); + return false; + }, + compositionend: (_view, event) => { + // ProseMirror records this timestamp only while it considers + // itself composing. Record it on the same condition, or a + // `compositionend` outside a composition would suppress a real + // Magic Keyboard Enter for the next 500 ms. + if (!_view.composing) return false; + compositionEndedAtRef.current = event.timeStamp; + return false; + }, + keydown: (_view, event) => { + if ( + !_view.editable || + !isIPadOSWebKitDevice || + !isIPadHardwareEnterCandidate(event) || + _view.composing || + event.isComposing || + event.keyCode === 229 + ) { + return false; + } + + // Match ProseMirror's Safari compositionend -> keydown safeguard. + // This custom DOM hook runs before ProseMirror's own keydown + // handler, so bypassing it here would otherwise submit an IME + // candidate confirmation. + if ( + Math.abs(event.timeStamp - compositionEndedAtRef.current) < + SAFARI_POST_COMPOSITION_KEYDOWN_WINDOW_MS + ) { + compositionEndedAtRef.current = Number.NEGATIVE_INFINITY; + postCompositionKeyDownEvents.add(event); + return false; + } + + // ProseMirror delays iOS Enter handling and later passes a + // synthetic Enter to handleKeyDown so the software keyboard can + // finish its DOM mutation. Only on the affected iPadOS WebKit path + // do we use the original event's physical code to handle a Magic + // Keyboard Enter before that fallback. Other platforms, including + // Android and coarse-pointer hybrids, stay entirely on + // ProseMirror's normal path. + // + // A handled event stops ProseMirror's own `keydown` handler, which + // is also where ProseMirror flushes its DOM observer. That is safe + // here: every deferred-flush path in ProseMirror needs either IE11 + // or an active composition, and the composition check above already + // excludes the second one. So the observer has flushed already and + // the submit reads a current document. + return handleEditorKeyDownRef.current(event, true); + }, + click: (_view, event) => { + return suppressPromptEditorAnchorActivation(event); + }, + }, + handleClick: () => { + const currentEditor = editorRef.current; + if (!currentEditor) return false; + syncTriggerStateRef.current(currentEditor); + return false; + }, + handleKeyDown: (_view, event) => { + return handleEditorKeyDownRef.current(event); + }, + handlePaste: (view, event, slice) => { + const attachFiles = onAttachFilesRef.current; + const clipboardItems = Array.from(event.clipboardData?.items ?? []); + const pastedFiles = clipboardItems + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + + if (attachFiles && pastedFiles.length > 0) { + event.preventDefault(); + void attachFiles(pastedFiles); + return true; + } + + const plainText = event.clipboardData?.getData("text/plain") ?? ""; + const sliceHasBlockquote = promptEditorSliceHasBlockquote(slice); + if (sliceHasBlockquote || plainTextHasQuoteLine(plainText)) { + event.preventDefault(); + const pastedValue = trimTrailingPromptNewlines( + sliceHasBlockquote + ? promptEditorValueFromSlice(slice, view.state.schema) + : promptEditorValueFromPlainText(plainText, promptActions), + ); + if (pastedValue.text.length === 0) return true; + + const currentEditor = editorRef.current; + const pastedContent = + promptEditorContentFromValue(pastedValue, { + richTextMarkdown: richTextEditing, + }).content ?? []; + currentEditor?.chain().focus().insertContent(pastedContent).run(); + if (currentEditor && !currentEditor.isDestroyed) { + const nextValue = trimTrailingPromptNewlines( + promptEditorValueFromDoc(currentEditor.state.doc), + ); + editorValueKeyRef.current = promptEditorValueKey(nextValue); + onChangeRef.current(nextValue.text, nextValue.mentions); + } + return true; + } + + const pastedValue = promptEditorValueFromClipboardPaste( + event.clipboardData ?? null, + promptActions, + ); + if (pastedValue === null) return false; + + event.preventDefault(); + if (pastedValue.text.length === 0) return true; + + editorRef.current + ?.chain() + .focus() + .insertContent(promptEditorInlineContentFromValue(pastedValue)) + .run(); + return true; + }, + }, + onCreate({ editor: createdEditor }) { + editorRef.current = createdEditor; + editorValueKeyRef.current = promptEditorValueKey({ + text: value, + mentions: mentionRanges, + }); + }, + onSelectionUpdate({ editor: updatedEditor }) { + syncTriggerStateRef.current(updatedEditor); + scheduleRevealEditorSelection(); + }, + onUpdate({ editor: updatedEditor }) { + if (skipEditorChangeRef.current) return; + const nextValue = promptEditorValueFromDoc(updatedEditor.state.doc); + editorValueKeyRef.current = promptEditorValueKey(nextValue); + onChangeRef.current(nextValue.text, nextValue.mentions); + syncTriggerStateRef.current(updatedEditor); + scheduleRevealEditorSelection(); + }, + // Rebuild the editor when the rich-text preference toggles so the schema + // and input rules switch. The editor is otherwise created once; its + // handlers route through refs (above) to stay current without rebuilding. + }, + [richTextEditing], + ); + + useEffect(() => { + if (!editor || editor.isDestroyed) return; + const editable = !composerInputLocked; + if (editor.isEditable !== editable) editor.setEditable(editable); + }, [composerInputLocked, editor]); + + useEffect(() => { + editorRef.current = editor; + }, [editor]); + + useEffect(() => { + if (!editor || editor.isDestroyed) return; + refreshPromptDecorations(editor); + }, [editor, pluginRichTextContributions]); + + useLayoutEffect(() => { + if (!pendingFocusEndRef.current) return; + + if (isPointerCoarse) { + pendingFocusEndRef.current = false; + return; + } + if (!editor) return; + pendingFocusEndRef.current = false; + focusEditorAtEnd(editor); + scheduleRevealEditorSelection(); + }, [editor, isPointerCoarse, scheduleRevealEditorSelection]); + + useLayoutEffect(() => { + placeholderRef.current = effectivePlaceholder; + if (!editor) return; + + editor.view.dom.setAttribute("aria-label", effectivePlaceholder); + editor.view.dom.setAttribute("data-placeholder", effectivePlaceholder); + editor.view.dom.setAttribute("enterkeyhint", editorEnterKeyHint); + editor.view.dispatch(editor.state.tr); + }, [editor, editorEnterKeyHint, effectivePlaceholder]); + + useEffect(() => { + if (shouldAvoidSoftKeyboardAutofocus) return; + if (!editor) return; + + const focusEditor = () => { + if (editor.isDestroyed) return; + focusEditorAtEnd(editor); + scheduleRevealEditorSelection(); + }; + + if (typeof window.requestAnimationFrame !== "function") { + focusEditor(); + return; + } + + const handle = window.requestAnimationFrame(focusEditor); + return () => window.cancelAnimationFrame(handle); + }, [ + editor, + focusScopeKey, + scheduleRevealEditorSelection, + shouldAvoidSoftKeyboardAutofocus, + ]); + + useEffect(() => { + mentionRangesRef.current = mentionRanges; + }, [mentionRanges]); + + useEffect(() => { + valueRef.current = value; + }, [value]); + + useLayoutEffect(() => { + if (!editor) return; + const nextValue = { + text: value, + mentions: mentionRanges, + }; + const nextKey = promptEditorValueKey(nextValue); + if (nextKey === editorValueKeyRef.current) { + return; + } + + try { + skipEditorChangeRef.current = true; + editor.commands.setContent( + promptEditorContentFromValue(nextValue, { + richTextMarkdown: richTextEditing, + }), + ); + editorValueKeyRef.current = nextKey; + } finally { + skipEditorChangeRef.current = false; + } + syncTriggerState(editor); + scheduleRevealEditorSelection(); + }, [ + editor, + mentionRanges, + richTextEditing, + scheduleRevealEditorSelection, + syncTriggerState, + value, + ]); + + // An explicit draft-restore action (e.g. editing a queued message) bumps + // `focusEndKey` so the caret lands at the END of the restored text. It is a + // layout effect defined AFTER the layout content-sync effect above, so the + // editor has already applied `setContent` for the new draft in the same + // commit. Mobile web deliberately does not take focus here: an action that + // opens or updates a composer must not summon the soft keyboard over the + // destination surface. + const lastFocusEndKeyRef = useRef(focusEndKey); + useLayoutEffect(() => { + if (focusEndKey === undefined) return; + if (focusEndKey === lastFocusEndKeyRef.current) return; + if (isPointerCoarse) { + lastFocusEndKeyRef.current = focusEndKey; + return; + } + if (!editor) return; + lastFocusEndKeyRef.current = focusEndKey; + focusEditorAtEnd(editor); + scheduleRevealEditorSelection(); + }, [editor, focusEndKey, isPointerCoarse, scheduleRevealEditorSelection]); + + useEffect(() => { + if (zenModeResetKey === undefined) return; + if (resolvedZenModeStorageKey) { + setIsZenMode(RESET); + return; + } + setIsZenMode(false); + }, [resolvedZenModeStorageKey, setIsZenMode, zenModeResetKey]); + + useLayoutEffect(() => { + scheduleRevealEditorSelection(); + }, [isZenMode, minHeight, scheduleRevealEditorSelection]); + + const resetHistorySession = useCallback(() => { + setActiveHistoryIndex(null); + setTemporaryHistoryDraft(null); + setRecalledHistoryDraft(null); + }, []); + + useEffect(() => { + if (!history) { + resetHistorySession(); + return; + } + if (history.entries.length === 0) { + resetHistorySession(); + return; + } + if ( + activeHistoryIndex !== null && + activeHistoryIndex >= history.entries.length + ) { + resetHistorySession(); + } + }, [activeHistoryIndex, history, resetHistorySession]); + + useEffect(() => { + resetHistorySession(); + }, [history?.resetKey, resetHistorySession]); + + useEffect(() => { + if (!history || activeHistoryIndex === null || !recalledHistoryDraft) { + return; + } + const activeHistoryEntry = history.entries[activeHistoryIndex]; + if ( + !activeHistoryEntry || + !arePromptDraftStatesEqual(activeHistoryEntry, recalledHistoryDraft) + ) { + resetHistorySession(); + return; + } + if (arePromptDraftStatesEqual(history.currentDraft, recalledHistoryDraft)) { + return; + } + resetHistorySession(); + }, [activeHistoryIndex, history, recalledHistoryDraft, resetHistorySession]); + + useLayoutEffect(() => { + const fromHeight = heightAnimationFromRef.current; + const formElement = formRef.current; + if (fromHeight === null || !formElement) return; + heightAnimationFromRef.current = null; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const previousTransition = formElement.style.transition; + const previousWillChange = formElement.style.willChange; + const previousOverflow = formElement.style.overflow; + + formElement.style.transition = "none"; + formElement.style.height = ""; + const toHeight = formElement.getBoundingClientRect().height; + if (Math.abs(toHeight - fromHeight) < 0.5) { + formElement.style.transition = previousTransition; + return; + } + formElement.style.height = `${fromHeight}px`; + formElement.getBoundingClientRect(); + // The next layout is already mounted while the card still has its old + // height. Clip it for the whole tween so footer controls are revealed by + // the moving border instead of briefly painting outside the card. + formElement.style.overflow = "hidden"; + formElement.style.willChange = "height"; + formElement.style.transition = + "height 240ms cubic-bezier(0.22, 1, 0.36, 1)"; + formElement.style.height = `${toHeight}px`; + + let isCleanedUp = false; + const cleanup = () => { + if (isCleanedUp) return; + isCleanedUp = true; + formElement.style.transition = previousTransition; + formElement.style.willChange = previousWillChange; + formElement.style.overflow = previousOverflow; + formElement.style.height = ""; + formElement.removeEventListener("transitionend", handleTransitionEnd); + window.clearTimeout(fallbackTimeout); + }; + const handleTransitionEnd = (event: TransitionEvent) => { + if (event.propertyName !== "height") return; + cleanup(); + }; + const fallbackTimeout = window.setTimeout(cleanup, 320); + formElement.addEventListener("transitionend", handleTransitionEnd); + + return cleanup; + }, [heightAnimationKey, isZenMode, showCompactLayout, zenModeLayout]); + + const trimmedValue = value.trim(); + const hasAttachments = attachments.length > 0; + const hasSubmittableInput = trimmedValue.length > 0 || hasAttachments; + + const activeTriggerKind = activeTrigger?.kind ?? null; + const commandHasMore = typeahead.command.hasMore; + const commandIsLoadingMore = typeahead.command.isLoadingMore; + const loadMoreCommands = typeahead.command.loadMore; + const canLoadMoreCommands = + activeTriggerKind === "command" && + canLoadMoreCommandResults({ + hasMore: commandHasMore, + isError: commandError, + isLoadingMore: commandIsLoadingMore, + }); + const orderedCommandSuggestions = useMemo( + () => orderCommandSuggestionsBySection(commandSuggestions), + [commandSuggestions], + ); + // The suggestion list driving keyboard nav + Enter/Tab apply for whichever + // trigger is active. Empty when no trigger is open. Memoized so the keyboard + // handler's useCallback identity is stable across renders. + const activeSuggestions = useMemo( + () => + activeTriggerKind === "command" + ? orderedCommandSuggestions + : activeTriggerKind === "mention" + ? mentionSuggestions + : [], + [activeTriggerKind, mentionSuggestions, orderedCommandSuggestions], + ); + + const activeMentionQuery = + activeTrigger?.kind === "mention" ? activeTrigger.query.trim() : ""; + const mentionMenuState: MentionMenuState = + activeMentionQuery.length === 0 + ? { kind: "hint" } + : mentionLoading + ? { kind: "loading" } + : mentionError + ? { kind: "error" } + : { kind: "results", suggestions: mentionSuggestions }; + + const commandMenuState: CommandMenuState = commandLoading + ? { kind: "loading" } + : commandError + ? { kind: "error" } + : { kind: "results", suggestions: orderedCommandSuggestions }; + + // Loaded-empty suppression (§6): a command trigger with zero loaded results + // (not loading, not error) is literal text — never open the menu. Mention + // triggers always open (they have a hint / "no matches" state). + const isCommandTriggerLiteral = + activeTriggerKind === "command" && + !commandLoading && + !commandError && + commandSuggestions.length === 0; + const isBareNonDefaultMentionTrigger = + activeTrigger?.kind === "mention" && + activeTrigger.char !== DEFAULT_PLUGIN_MENTION_TRIGGER && + activeMentionQuery.length === 0; + const showTypeaheadMenu = + activeTrigger !== null && + !isCommandTriggerLiteral && + !isBareNonDefaultMentionTrigger; + + const typeaheadMenuState: TypeaheadMenuState = + activeTriggerKind === "command" + ? { trigger: "command", state: commandMenuState } + : { trigger: "mention", state: mentionMenuState }; + + useEffect(() => { + if (activeSuggestions.length === 0) { + setSelectedIndex(0); + return; + } + if (selectedIndex >= activeSuggestions.length) { + setSelectedIndex(0); + } + }, [activeSuggestions.length, selectedIndex]); + + useEffect(() => { + if ( + activeTriggerKind !== "command" || + !canLoadMoreCommands || + activeSuggestions.length === 0 + ) { + return; + } + const prefetchIndex = Math.max(0, activeSuggestions.length - 3); + if (selectedIndex >= prefetchIndex) { + loadMoreCommands(); + } + }, [ + activeSuggestions.length, + activeTriggerKind, + canLoadMoreCommands, + loadMoreCommands, + selectedIndex, + ]); + + // After applying any suggestion the editor content changed outside React's + // controlled flow; emit the controlled change, then re-focus, re-sync the + // trigger state, and reveal the caret on the next frame. Shared by the + // mention and command apply paths. + const finishApply = useCallback( + (appliedEditor: Editor) => { + const nextValue = promptEditorValueFromDoc(appliedEditor.state.doc); + editorValueKeyRef.current = promptEditorValueKey(nextValue); + onChangeRef.current(nextValue.text, nextValue.mentions); + + requestAnimationFrame(() => { + const nextEditor = editorRef.current; + if (!nextEditor || nextEditor.isDestroyed) { + isRestoringAppliedMentionRef.current = false; + return; + } + nextEditor.commands.focus(); + syncTriggerState(nextEditor); + scheduleRevealEditorSelection(); + isRestoringAppliedMentionRef.current = false; + }); + }, + [scheduleRevealEditorSelection, syncTriggerState], + ); + + const applyMentionSuggestion = useCallback( + (item: PromptMentionSuggestion) => { + const currentEditor = editorRef.current; + if (!currentEditor || activeTrigger?.kind !== "mention") return; + + const replacement = item.replacement.trim(); + const serializedText = replacement.startsWith(activeTrigger.char) + ? replacement + : `${activeTrigger.char}${replacement}`; + const resource = promptMentionResourceFromSuggestion(item); + const trailingText = hasWhitespaceAfterPosition( + currentEditor.state.doc, + activeTrigger.to, + ) + ? "" + : " "; + triggerKeyRef.current = ""; + // Mention dismissed-range basis is node width: trigger char + the 1-wide + // pill atom in the post-replacement doc (`from` → `from + 2`). Do not + // change — pill re-trigger suppression depends on it. + dismissedTriggerRef.current = { + start: activeTrigger.from, + end: activeTrigger.from + 2, + hasLeftRange: false, + }; + isRestoringAppliedMentionRef.current = true; + setActiveTrigger(null); + setSelectedIndex(0); + onMentionQueryChange(null, null); + + try { + skipEditorChangeRef.current = true; + currentEditor + .chain() + .focus() + .deleteRange({ from: activeTrigger.from, to: activeTrigger.to }) + .insertContent([ + { + type: "mention", + attrs: { + resource, + serializedText, + }, + }, + ...(trailingText ? [{ type: "text", text: trailingText }] : []), + ]) + .run(); + } finally { + skipEditorChangeRef.current = false; + } + finishApply(currentEditor); + }, + [activeTrigger, finishApply, onMentionQueryChange], + ); + + const applyCommandSuggestion = useCallback( + (item: ProviderCommandSuggestion) => { + const currentEditor = editorRef.current; + if (!currentEditor || activeTrigger === null) return; + if (activeTrigger.char !== "/") return; + + const serializedText = `${activeTrigger.char}${item.name}`; + const resource = promptCommandResourceFromSuggestion({ + suggestion: item, + trigger: activeTrigger.char, + }); + const trailingText = hasWhitespaceAfterPosition( + currentEditor.state.doc, + activeTrigger.to, + ) + ? "" + : " "; + triggerKeyRef.current = ""; + // Argument hints render as placeholder decorations, not editor text. + dismissedTriggerRef.current = { + start: activeTrigger.from, + end: commandPillDismissedRangeEnd({ + triggerPosition: activeTrigger.from, + trailingText, + }), + hasLeftRange: false, + }; + isRestoringAppliedMentionRef.current = true; + setActiveTrigger(null); + setSelectedIndex(0); + onCommandQueryChange(null); + + try { + skipEditorChangeRef.current = true; + currentEditor + .chain() + .focus() + .deleteRange({ from: activeTrigger.from, to: activeTrigger.to }) + .insertContent([ + { + type: "mention", + attrs: { + resource, + serializedText, + }, + }, + ...(trailingText ? [{ type: "text", text: trailingText }] : []), + ]) + .run(); + } finally { + skipEditorChangeRef.current = false; + } + finishApply(currentEditor); + }, + [activeTrigger, finishApply, onCommandQueryChange], + ); + + const applyTrigger = useCallback( + (item: TypeaheadSuggestion) => { + if (item.kind === "command") { + applyCommandSuggestion(item); + return; + } + applyMentionSuggestion(item); + }, + [applyCommandSuggestion, applyMentionSuggestion], + ); + + const focusEnd = useCallback(() => { + if (isPointerCoarse) { + pendingFocusEndRef.current = false; + return; + } + const currentEditor = editorRef.current; + if (!currentEditor || currentEditor.isDestroyed) { + pendingFocusEndRef.current = true; + return; + } + pendingFocusEndRef.current = false; + focusEditorAtEnd(currentEditor); + scheduleRevealEditorSelection(); + }, [isPointerCoarse, scheduleRevealEditorSelection]); + + const insertTextAtCursor = useCallback( + (rawText: string) => { + const normalizedText = rawText.replace(/\s+/g, " ").trim(); + if (normalizedText.length === 0) return; + + const currentEditor = editorRef.current; + const currentValue = valueRef.current; + if (!currentEditor) { + const nextValue = + currentValue.length === 0 || /\s$/.test(currentValue) + ? `${currentValue}${normalizedText}` + : `${currentValue} ${normalizedText}`; + onChangeRef.current(nextValue, [...mentionRangesRef.current]); + return; + } + + const selection = currentEditor.state.selection; + const before = currentEditor.state.doc.textBetween( + 0, + selection.from, + "\n", + "\n", + ); + const after = currentEditor.state.doc.textBetween( + selection.to, + currentEditor.state.doc.content.size, + "\n", + "\n", + ); + const needsLeadingWhitespace = before.length > 0 && !/\s$/.test(before); + const needsTrailingWhitespace = after.length > 0 && !/^\s/.test(after); + const insertedText = `${needsLeadingWhitespace ? " " : ""}${normalizedText}${needsTrailingWhitespace ? " " : ""}`; + + const insertion = currentEditor.chain(); + if (!isPointerCoarse) insertion.focus(); + insertion.insertContent(insertedText).run(); + if (!isPointerCoarse) scheduleRevealEditorSelection(); + }, + [isPointerCoarse, scheduleRevealEditorSelection], + ); + + const focusAfterPromptAction = useCallback( + (currentEditor: Editor) => { + const focusEditor = () => { + promptActionFocusFrameRef.current = null; + if (currentEditor.isDestroyed) return; + currentEditor.commands.focus(); + syncTriggerState(currentEditor); + scheduleRevealEditorSelection(); + }; + + if (typeof requestAnimationFrame !== "function") { + focusEditor(); + return; + } + + if (promptActionFocusFrameRef.current !== null) { + cancelAnimationFrame(promptActionFocusFrameRef.current); + } + promptActionFocusFrameRef.current = requestAnimationFrame(focusEditor); + }, + [scheduleRevealEditorSelection, syncTriggerState], + ); + + const applyPromptAction = useCallback( + (action: PromptBoxAction) => { + if (action.text.length === 0) return; + const commandAction = promptActionCommandFromAction(action); + + 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, + ]); + } + return; + } + + if (promptActionTextImmediatelyBeforeCursor(currentEditor, action.text)) { + focusAfterPromptAction(currentEditor); + return; + } + + const insertionRange = getPromptActionInsertionRange({ + editor: currentEditor, + action, + actions: promptActions ?? [], + triggers: promptActionTriggers(triggers, commandAction), + }); + if (insertionRange === null) { + focusAfterPromptAction(currentEditor); + return; + } + + if (commandAction) { + triggerKeyRef.current = ""; + dismissedTriggerRef.current = null; + isRestoringAppliedMentionRef.current = true; + setActiveTrigger(null); + setSelectedIndex(0); + onCommandQueryChange(null); + + try { + skipEditorChangeRef.current = true; + currentEditor + .chain() + .focus() + .deleteRange({ from: insertionRange.from, to: insertionRange.to }) + .insertContent([ + { + type: "mention", + attrs: { + resource: promptCommandResourceFromSuggestion({ + suggestion: commandAction.suggestion, + trigger: commandAction.trigger, + }), + serializedText: commandAction.serializedText, + }, + }, + ...(commandAction.trailingText + ? [{ type: "text", text: commandAction.trailingText }] + : []), + ]) + .run(); + } finally { + skipEditorChangeRef.current = false; + } + finishApply(currentEditor); + return; + } + + triggerKeyRef.current = ""; + dismissedTriggerRef.current = null; + setSelectedIndex(0); + currentEditor + .chain() + .focus() + .deleteRange({ from: insertionRange.from, to: insertionRange.to }) + .insertContent(action.text) + .run(); + finishApply(currentEditor); + }, + [ + finishApply, + focusAfterPromptAction, + onCommandQueryChange, + promptActions, + triggers, + ], + ); + + const getTextBeforeCursor = useCallback((): string | undefined => { + const currentValue = valueRef.current; + const currentEditor = editorRef.current; + if (!currentEditor) { + const trimmed = currentValue.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + const beforeCursor = currentEditor.state.doc + .textBetween(0, currentEditor.state.selection.from, "\n", "\n") + .trim(); + return beforeCursor.length > 0 ? beforeCursor : undefined; + }, []); + + useImperativeHandle( + promptBoxRef, + () => ({ + captureHeightForLayoutChange: capturePromptBoxHeight, + focusEnd, + insertTextAtCursor, + getTextBeforeCursor, + }), + [capturePromptBoxHeight, focusEnd, insertTextAtCursor, getTextBeforeCursor], + ); + + const canSubmit = + hasSubmittableInput && !isSubmitting && !submitDisabled && !isVoiceBusy; + const canModifierSubmit = + onModifierSubmit !== undefined && + !isSubmitting && + !submitDisabled && + !isVoiceBusy; + const showStop = Boolean(isRunning && onStop && !canSubmit && !isVoiceBusy); + const canStartVoiceInput = + voice !== undefined && voice.isSupported && !isSubmitting; + const showVoiceAsPrimaryAction = + isPointerCoarse && !hasSubmittableInput && canStartVoiceInput; + const handleVoicePointerDown = useCallback( + (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(); + }, + [isPointerCoarse], + ); + const startVoiceInput = useCallback(() => { + if (isPointerCoarse) { + const currentEditor = editorRef.current; + if (currentEditor && !currentEditor.isDestroyed) { + blurActiveKeyboardInputWithin(currentEditor.view.dom); + } + } + void voice?.start(); + }, [isPointerCoarse, voice]); + const effectiveSubmitTitle = isZenMode + ? submitTitle.replace(/^Submit\s+/, "") + : submitTitle; + + const emitAttachmentFiles = useCallback( + (files: File[]) => { + if (!onAttachFiles || files.length === 0) return; + void onAttachFiles(files); + }, + [onAttachFiles], + ); + + const resetZenModeAfterSubmit = useCallback(() => { + if (!resetZenModeOnSubmit || !isZenMode) return; + if (resolvedZenModeStorageKey) { + setIsZenMode(RESET); + return; + } + setIsZenMode(false); + }, [ + isZenMode, + resetZenModeOnSubmit, + resolvedZenModeStorageKey, + setIsZenMode, + ]); + + const submitPrompt = useCallback(() => { + if (!canSubmit) return; + onSubmit(); + resetZenModeAfterSubmit(); + }, [canSubmit, onSubmit, resetZenModeAfterSubmit]); + + const handleSubmitPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + const currentEditor = editorRef.current; + if ( + !currentEditor || + currentEditor.isDestroyed || + !currentEditor.isFocused + ) { + return; + } + + // Focus transfer happens before click. On iOS, moving focus from the + // editor to this button begins keyboard dismissal and resizes the app + // shell before the form can submit. Keep the editor focused; the click + // still owns the commit, while genuine outside focus dismisses normally. + event.preventDefault(); + }, + [], + ); + + // A no-argument built-in command (currently only `/compact`) is a complete + // action the moment it is selected, so applying it with Enter should also + // submit instead of leaving the pill parked for a second Enter. The submit is + // deferred to this effect — keyed on the flag — so `onSubmit` runs after the + // applied command mention has propagated into the parent draft (applying the + // pill updates the draft on the next render, not synchronously). + const [pendingCommandSubmit, setPendingCommandSubmit] = useState(false); + useEffect(() => { + if (!pendingCommandSubmit) return; + setPendingCommandSubmit(false); + submitPrompt(); + }, [pendingCommandSubmit, submitPrompt]); + + const submitModifierPrompt = useCallback(() => { + if (!canModifierSubmit || !onModifierSubmit) return; + onModifierSubmit(); + resetZenModeAfterSubmit(); + }, [canModifierSubmit, onModifierSubmit, resetZenModeAfterSubmit]); + + const applyHistoryDraft = useCallback( + (draft: PromptDraftState) => { + if (!history) { + return; + } + + history.onSelectEntry(draft); + requestAnimationFrame(() => { + const currentEditor = editorRef.current; + if (!currentEditor || currentEditor.isDestroyed) { + return; + } + + focusEditorAtEnd(currentEditor); + syncTriggerState(currentEditor); + scheduleRevealEditorSelection(); + }); + }, + [history, scheduleRevealEditorSelection, syncTriggerState], + ); + + const focusEditorAfterSizeChange = useCallback(() => { + // Size changes on mobile web are presentation-only. Keeping focus where it + // is prevents the soft keyboard from covering the thread after a tap. + if (isPointerCoarse) return; + requestAnimationFrame(() => { + const currentEditor = editorRef.current; + if (!currentEditor || currentEditor.isDestroyed) return; + + currentEditor.commands.focus(); + scheduleRevealEditorSelection(); + }); + }, [isPointerCoarse, scheduleRevealEditorSelection]); + + const exitZenMode = useCallback(() => { + capturePromptBoxHeight(); + if (!isZenMode) return; + setIsZenMode(false); + focusEditorAfterSizeChange(); + }, [ + capturePromptBoxHeight, + focusEditorAfterSizeChange, + isZenMode, + setIsZenMode, + ]); + + const enterZenMode = useCallback(() => { + capturePromptBoxHeight(); + // Mobile follow-up composers expand by focus, not a manual size control. + if (compact) return; + if (isZenMode) return; + setIsZenMode(true); + focusEditorAfterSizeChange(); + }, [ + capturePromptBoxHeight, + focusEditorAfterSizeChange, + isZenMode, + compact, + setIsZenMode, + ]); + + const handleAttachmentInputChange = useCallback( + (event: ChangeEvent) => { + const fileList = event.target.files; + if (!fileList || fileList.length === 0) return; + emitAttachmentFiles(Array.from(fileList)); + event.target.value = ""; + }, + [emitAttachmentFiles], + ); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + submitPrompt(); + }; + + const handlePromptBoxMouseDown = useCallback( + (event: PromptBoxMouseDownEvent) => { + if (!isPromptBoxChromeTarget(event.target)) return; + + const currentEditor = editorRef.current; + if (!currentEditor || currentEditor.isDestroyed) return; + + event.preventDefault(); + focusEditorAtEnd(currentEditor); + scheduleRevealEditorSelection(); + }, + [scheduleRevealEditorSelection], + ); + + const handleEditorKeyDown = useCallback( + (event: KeyboardEvent, isOriginalIPadHardwareEnter = false): boolean => { + // An IME keystroke must reach neither an app chord nor a submit. The + // WeakSet carries the iPadOS hook's decision, because that hook runs + // before ProseMirror's own post-composition safeguard. + if ( + event.isComposing || + event.keyCode === 229 || + postCompositionKeyDownEvents.has(event) + ) { + return false; + } + // App keybindings win over the editor's own keymap. TipTap cancels the + // chords it knows (Mod+Shift+B for a blockquote, Mod+B, Mod+Shift+7/8 for + // lists), and the window listener skips a canceled event — so without + // this an app chord silently did nothing while the composer had focus. + if (dispatchAppCommandKey(event)) { + return true; + } + const canSubmitWithEnterKey = + !isPointerCoarse || isOriginalIPadHardwareEnter; + const currentEditor = editorRef.current; + const selection = currentEditor?.state.selection; + const hasCollapsedSelection = Boolean(selection?.empty); + const hasArrowNavigationModifier = + event.shiftKey || event.altKey || event.metaKey || event.ctrlKey; + const hasCursorAtEnd = + hasCollapsedSelection && + currentEditor !== null && + currentEditor !== undefined && + selection !== undefined && + selection.from >= currentEditor.state.doc.content.size - 1; + const activeHistoryEntry = + history && activeHistoryIndex !== null + ? history.entries[activeHistoryIndex] + : null; + const hasSelectedHistoryEntry = Boolean( + history && + activeHistoryEntry !== null && + activeHistoryEntry !== undefined && + arePromptDraftStatesEqual(history.currentDraft, activeHistoryEntry), + ); + const canNavigateHistory = + history !== undefined && + !hasArrowNavigationModifier && + hasCursorAtEnd && + (isPromptDraftEmpty(history.currentDraft) || hasSelectedHistoryEntry); + const canNavigateTypeahead = + showTypeaheadMenu && !hasArrowNavigationModifier && !canNavigateHistory; + + if (showTypeaheadMenu) { + if ( + event.key === "ArrowDown" && + canNavigateTypeahead && + activeSuggestions.length > 0 + ) { + event.preventDefault(); + if ( + activeTriggerKind === "command" && + !commandError && + selectedIndex >= activeSuggestions.length - 1 && + (commandHasMore || commandIsLoadingMore) + ) { + if (canLoadMoreCommands) { + loadMoreCommands(); + } + return true; + } + setSelectedIndex((prev) => (prev + 1) % activeSuggestions.length); + return true; + } + if ( + event.key === "ArrowUp" && + canNavigateTypeahead && + activeSuggestions.length > 0 + ) { + event.preventDefault(); + setSelectedIndex( + (prev) => + (prev + activeSuggestions.length - 1) % activeSuggestions.length, + ); + return true; + } + if ( + (event.key === "Enter" || event.key === "Tab") && + activeSuggestions.length > 0 + ) { + event.preventDefault(); + const selected = + activeSuggestions[selectedIndex] ?? activeSuggestions[0]; + if (selected) { + applyTrigger(selected); + // Built-in commands (e.g. `/compact`) take no arguments, so picking + // one with Enter both inserts the pill and submits. Tab still only + // inserts, and mention suggestions are unaffected. + if ( + event.key === "Enter" && + selected.kind === "command" && + selected.origin === "builtin" + ) { + setPendingCommandSubmit(true); + } + } + return true; + } + if (event.key === "Escape") { + event.preventDefault(); + triggerKeyRef.current = ""; + if (activeTrigger) { + // Escape dismisses the typed token span for both kinds — re-trigger + // stays suppressed while the caret remains inside `[from, to]`. + dismissedTriggerRef.current = { + start: activeTrigger.from, + end: activeTrigger.to, + hasLeftRange: false, + }; + } + setActiveTrigger(null); + onMentionQueryChange(null, null); + onCommandQueryChange(null); + return true; + } + } + + // Escape releases the composer so the keyboard can reach the rest of the + // app. Higher-priority Escape behavior still runs first: the typeahead + // menu above dismisses itself, and voice recording cancels from a window + // capture listener that stops the event before the editor sees it. A + // locked editor never reaches here — see the editor container below. + if (event.key === "Escape") { + blurPromptEditor(currentEditor); + return true; + } + + if (history) { + if ( + event.key === "ArrowUp" && + canNavigateHistory && + history.entries.length > 0 + ) { + event.preventDefault(); + const nextHistoryIndex = + activeHistoryIndex === null + ? 0 + : Math.min(activeHistoryIndex + 1, history.entries.length - 1); + if (activeHistoryIndex === null) { + setTemporaryHistoryDraft(history.currentDraft); + } + setActiveHistoryIndex(nextHistoryIndex); + const nextDraft = history.entries[nextHistoryIndex]; + setRecalledHistoryDraft(nextDraft); + applyHistoryDraft(nextDraft); + return true; + } + + if ( + event.key === "ArrowDown" && + canNavigateHistory && + activeHistoryIndex !== null + ) { + event.preventDefault(); + if (activeHistoryIndex === 0) { + if (temporaryHistoryDraft) { + applyHistoryDraft(temporaryHistoryDraft); + } + resetHistorySession(); + return true; + } + + const nextHistoryIndex = activeHistoryIndex - 1; + setActiveHistoryIndex(nextHistoryIndex); + const nextDraft = history.entries[nextHistoryIndex]; + setRecalledHistoryDraft(nextDraft); + applyHistoryDraft(nextDraft); + return true; + } + } + + const isModifierSubmitKey = + event.key === "Enter" && + event.metaKey && + !event.shiftKey && + !event.altKey && + !event.ctrlKey; + if (isModifierSubmitKey && onModifierSubmit) { + event.preventDefault(); + submitModifierPrompt(); + return true; + } + + const isBlockquoteExitKey = + event.key === "Enter" && + event.shiftKey && + !event.metaKey && + !event.altKey && + !event.ctrlKey; + if ( + isBlockquoteExitKey && + currentEditor && + applyPromptListNewline(currentEditor) + ) { + event.preventDefault(); + return true; + } + + if ( + isBlockquoteExitKey && + currentEditor && + (insertParagraphBeforeBlockquote(currentEditor) || + exitTrailingBlockquoteBreak(currentEditor)) + ) { + event.preventDefault(); + return true; + } + + const isPromptNewlineKey = + event.key === "Enter" && + !event.metaKey && + !event.altKey && + !event.ctrlKey && + (event.shiftKey || isZenMode || !canSubmitWithEnterKey); + if (isPromptNewlineKey && currentEditor && exitHeading(currentEditor)) { + event.preventDefault(); + return true; + } + + if ( + isPromptNewlineKey && + currentEditor && + applyPromptParagraphNewline(currentEditor) + ) { + event.preventDefault(); + return true; + } + + if (isZenMode || !canSubmitWithEnterKey) return false; + const isSubmitKey = event.key === "Enter" && !event.shiftKey; + + if (!isSubmitKey) return false; + event.preventDefault(); + submitPrompt(); + return true; + }, + [ + activeHistoryIndex, + activeSuggestions, + activeTrigger, + activeTriggerKind, + applyHistoryDraft, + applyTrigger, + canLoadMoreCommands, + commandError, + commandHasMore, + commandIsLoadingMore, + dispatchAppCommandKey, + history, + isPointerCoarse, + isZenMode, + loadMoreCommands, + onCommandQueryChange, + onMentionQueryChange, + onModifierSubmit, + postCompositionKeyDownEvents, + resetHistorySession, + selectedIndex, + setPendingCommandSubmit, + showTypeaheadMenu, + submitModifierPrompt, + submitPrompt, + temporaryHistoryDraft, + ], + ); + + useLayoutEffect(() => { + handleEditorKeyDownRef.current = handleEditorKeyDown; + }, [handleEditorKeyDown]); + + // Capture phase + stopPropagation so Escape cancels the recording and wins + // over the composer's own Escape-to-dismiss (which would otherwise hide the + // whole box), instead of leaking to the collapsed editor. + useEffect(() => { + if (!showVoiceActionGroup || !voice) return; + const cancelVoice = voice.cancel; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + cancelVoice(); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [showVoiceActionGroup, voice]); + + return ( +
{ + if (!onAttachFiles) return; + event.preventDefault(); + }} + onDrop={(event) => { + if (!onAttachFiles) return; + event.preventDefault(); + if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) + return; + emitAttachmentFiles(Array.from(event.dataTransfer.files)); + }} + className={cn( + "group/promptbox relative w-full rounded-xl border border-border bg-background shadow-lift", + "transition-[border-radius] duration-[180ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none", + showVoiceActionGroup && "rounded-3xl", + showCompactLayout && "overflow-hidden", + // Zen toggles only the *height* of the box; the inset padding stays + // identical so the placeholder/text doesn't jump when toggling. + // `flex flex-col` lets the editor's `flex-1` fill the dvh height. + showZenLayout && "flex flex-col", + showZenLayout && ZEN_MODE_HEIGHT_CLASS[zenModeLayout], + className, + )} + > + +
+
+ {header && !showCompactLayout ? ( + // Left padding matches the editor's so the header content aligns + // with the placeholder column in both normal and zen modes (editor + // shifts from px-4 to px-6 when entering zen). Right padding leaves + // room for the zen-mode toggle button in the top-right corner. Zen + // mode also gets more top room since the card fills the viewport. +
+ {header} +
+ ) : null} +
+ {!showCompactLayout ? ( + <> +
+ +
+
+ {isZenMode ? ( + + ) : null} + {!isZenMode && !compact ? ( + + ) : null} +
+ + ) : null} +
+ + { + if (event.key !== "Escape") return; + if (editor === null || editor.isEditable) return; + event.preventDefault(); + blurPromptEditor(editor); + }} + data-promptbox-editor-content="" + data-promptbox-compact-content={ + showCompactLayout ? "" : undefined + } + className={cn( + "h-full min-h-full", + showCompactLayout && "flex items-center", + "[&_.ProseMirror]:min-h-full [&_.ProseMirror]:leading-[1.7] [&_.ProseMirror]:outline-none", + "[&_.ProseMirror_p]:m-0", + "[&_.ProseMirror_blockquote]:my-1 [&_.ProseMirror_blockquote]:border-l-2 [&_.ProseMirror_blockquote]:border-surface-selected-border [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_blockquote]:text-muted-foreground", + // Markdown formatting styles (mirrors what the timeline renders). + "[&_.ProseMirror_h1]:my-1 [&_.ProseMirror_h1]:text-lg [&_.ProseMirror_h1]:font-semibold", + "[&_.ProseMirror_h2]:my-1 [&_.ProseMirror_h2]:text-base [&_.ProseMirror_h2]:font-semibold", + "[&_.ProseMirror_h3]:my-1 [&_.ProseMirror_h3]:text-sm [&_.ProseMirror_h3]:font-semibold", + "[&_.ProseMirror_h4]:my-1 [&_.ProseMirror_h4]:text-sm [&_.ProseMirror_h4]:font-semibold [&_.ProseMirror_h5]:font-semibold [&_.ProseMirror_h6]:font-semibold", + "[&_.ProseMirror_ul]:my-1 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-5", + "[&_.ProseMirror_ol]:my-1 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-5", + "[&_.ProseMirror_li]:my-0.5 [&_.ProseMirror_li>p]:m-0", + "[&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:bg-surface-selected [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.9em]", + "[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none", + "[&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left", + "[&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0", + "[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-subtle-foreground", + "[&_.ProseMirror_p.is-editor-empty:first-child::before]:font-light", + "[&_.ProseMirror_p.is-editor-empty:first-child::before]:opacity-70", + )} + /> + +
+
+ + {showTypeaheadMenu ? ( +
+ +
+ ) : null} + + {!showCompactLayout ? ( + <> +
+ + + {attachmentError ? ( +
+ {attachmentError} +
+ ) : null} +
+ + ) : null} + + +
+ {!showCompactLayout ? ( +
+ attachmentInputRef.current?.click() + : undefined + } + onAction={applyPromptAction} + pluginItems={ + suppressPluginComposerCustomizations + ? [] + : pluginPlusMenuItems + } + /> + {footerStart} +
+ ) : null} +
+ {!showCompactLayout ? ( + <> + {!suppressPluginComposerCustomizations ? ( + + ) : null} + {voice && + !showVoiceActionGroup && + !showVoiceAsPrimaryAction ? ( + + ) : null} + + ) : null} +
+ {showStop ? ( + + ) : showVoiceAsPrimaryAction ? ( + + ) : ( + + )} +
+
+
+
+
+
+
+
+ {voice && showVoiceActionGroup ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/src/components/promptbox/prompt-box-runtime.ts b/apps/app/src/components/promptbox/prompt-box-runtime.ts new file mode 100644 index 0000000000..a6a452942e --- /dev/null +++ b/apps/app/src/components/promptbox/prompt-box-runtime.ts @@ -0,0 +1,21 @@ +import type { TypeaheadCommandConfig } from "./PromptBoxInternalImpl"; + +export const INERT_TYPEAHEAD_COMMAND_CONFIG: TypeaheadCommandConfig = { + trigger: null, + suggestions: [], + isLoading: false, + isError: false, + hasMore: false, + isLoadingMore: false, + loadMore: () => {}, + onQueryChange: () => {}, +}; + +export function suppressPromptEditorAnchorActivation(event: Event): boolean { + if (!(event.target instanceof Element)) return false; + if (event.target.closest("a[href]") === null) return false; + + event.preventDefault(); + event.stopPropagation(); + return true; +} diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx index 949845d154..ca506f63fa 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx @@ -13,7 +13,7 @@ import { FilePreview, buildCsvPreviewData, getCsvTruncationNote, -} from "./FilePreview"; +} from "./FilePreviewImpl"; import { SecondaryPanelFilePreview } from "./ThreadStorageFilePreview"; interface MockPierreFileProps { @@ -401,7 +401,7 @@ describe("FilePreview", () => { expect(getCsvTruncationNote(preview, preview.rows.length - 1)).toBeNull(); }); - it("uses the CSV table preview for loaded CSV text files", () => { + it("uses the CSV table preview for loaded CSV text files", async () => { render( { ); expect( - screen.getByRole("table", { name: "scores.csv CSV preview" }), + await screen.findByRole("table", { name: "scores.csv CSV preview" }), ).not.toBeNull(); expect(screen.getByRole("cell", { name: "Ada" })).not.toBeNull(); expect(screen.getByRole("cell", { name: "10" })).not.toBeNull(); diff --git a/apps/app/src/components/secondary-panel/FilePreview.tsx b/apps/app/src/components/secondary-panel/FilePreview.tsx index 5c31f17aec..80620898ce 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.tsx @@ -1,1350 +1,35 @@ -import { - type CSSProperties, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { File as PierreFile, useWorkerPool } from "@pierre/diffs/react"; -import type { FileOptions } from "@pierre/diffs/react"; -import type { SelectedLineRange, SupportedLanguages } from "@pierre/diffs"; -import type { UrlTransform } from "react-markdown"; -import { Button } from "@bb/shared-ui/button"; -import { usePierreLineSelectionActions } from "@/components/git-diff/PierreLineSelectionActions.js"; -import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; -import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; -import { CopyButton } from "@/components/ui/copy-button.js"; -import { Icon } from "@bb/shared-ui/icon"; -import { OpenInEditorButton } from "@/components/ui/open-in-editor-button.js"; -import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; -import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; -import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; -import { MarkdownPreview } from "@/components/ui/markdown-preview.js"; -import { Skeleton } from "@bb/shared-ui/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@bb/shared-ui/tooltip"; -import { TruncateStart } from "@/components/ui/truncate-start.js"; -import { usePreferredTheme } from "@/hooks/useTheme"; -import { copyToClipboardWithToast } from "@/lib/clipboard"; -import type { - FilePreviewLineRange, - WorkspaceFilePreviewStatusLabel, -} from "@/lib/file-preview"; -import { - DEFAULT_CODE_OVERFLOW_MODE, - type CodeOverflowMode, - type CodeOverflowModeChangeHandler, -} from "@/lib/code-overflow-mode"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { SecondaryPanelSelectionActions } from "./SecondaryPanelSelectionActions.js"; - -export interface FilePreviewFile { - cacheKey?: string; - name: string; - contents: string; - lang?: SupportedLanguages; -} - -export type IframePreviewSandbox = "allow-scripts"; - -export interface IframeFilePreviewTarget { - sandbox: IframePreviewSandbox | null; - title: string; - url: string; -} - -export type FilePreviewState = - | { kind: "loading" } - | { kind: "empty" } - | { kind: "not-found" } - | { kind: "error"; message?: string } - | { kind: "image"; url: string } - | { kind: "video"; url: string } - | ({ kind: "iframe" } & IframeFilePreviewTarget) - | { - kind: "html"; - file: FilePreviewFile; - iframe: IframeFilePreviewTarget; - lineRange: FilePreviewLineRange | null; - } - | { - kind: "ready"; - file: FilePreviewFile; - lineRange: FilePreviewLineRange | null; - textPreviewKind: TextFilePreviewKind | null; - markdownUrlTransform?: UrlTransform; - }; - -export interface FilePreviewProps { - state: FilePreviewState; - path: string; - copyPath?: string | null; - headerMode?: FilePreviewHeaderMode; - onSelectionAddToChat?: (text: string) => void; - onOpenInEditor?: (path: string) => void; - onRefresh?: () => void; - isRefreshing?: boolean; - markdownLinkRouting?: MarkdownLinkRouting; - statusLabel?: WorkspaceFilePreviewStatusLabel | null; -} - -interface FilePreviewBodyProps { - state: FilePreviewState; - path: string; - lineOverflowMode: CodeOverflowMode; - viewMode: FilePreviewViewMode; - markdownLinkRouting?: MarkdownLinkRouting; - onSelectionAddToChat?: (text: string) => void; -} - -interface HtmlFilePreviewBodyProps { - lineOverflowMode: CodeOverflowMode; - onSelectionAddToChat?: (text: string) => void; - state: Extract; - viewMode: FilePreviewViewMode; -} - -interface FilePreviewHeaderProps { - path: string; - copyPath: string | null; - rawContents: string | null; - onOpenInEditor?: (path: string) => void; - onRefresh?: () => void; - isRefreshing: boolean; - statusLabel: WorkspaceFilePreviewStatusLabel | null; - toggleKind: FilePreviewToggleKind | null; - showLineOverflowToggle: boolean; - lineOverflowMode: CodeOverflowMode; - onLineOverflowModeChange: CodeOverflowModeChangeHandler; - viewMode: FilePreviewViewMode; - onViewModeChange: (mode: FilePreviewViewMode) => void; -} - -interface FilePreviewLineWrapButtonProps { - showLineOverflowToggle: boolean; - lineOverflowMode: CodeOverflowMode; - onLineOverflowModeChange: CodeOverflowModeChangeHandler; -} - -interface FilePreviewPathProps { - path: string; - copyPath: string | null; -} - -interface MarkdownFilePreviewProps { - file: FilePreviewFile; - onSelectionAddToChat?: (text: string) => void; - urlTransform?: UrlTransform; - markdownLinkRouting?: MarkdownLinkRouting; -} - -interface CsvFilePreviewProps { - file: FilePreviewFile; - onSelectionAddToChat?: (text: string) => void; -} - -interface FilePreviewImageProps { - url: string; - alt: string; -} - -interface FilePreviewVideoProps { - url: string; - title: string; -} - -interface FilePreviewMessageProps { - message: string; - role?: "alert"; -} - -interface FilePreviewCodeProps { - file: FilePreviewFile; - lineOverflowMode: CodeOverflowMode; - lineRange: FilePreviewLineRange | null; - onSelectionAddToChat?: (text: string) => void; - path: string; -} - -interface FilePreviewWorkerPoolStats { - managerState: "waiting" | "initializing" | "initialized"; - workersFailed: boolean; - totalWorkers: number; - busyWorkers: number; - queuedTasks: number; - activeTasks: number; - themeSubscribers: number; - fileCacheSize: number; - diffCacheSize: number; -} - -interface GetInitialFilePreviewViewModeArgs { - lineRange: FilePreviewLineRange | null; - toggleKind: FilePreviewToggleKind | null; -} - -interface CsvPreviewData { - columnCount: number; - rows: string[][]; - truncatedColumns: boolean; - truncatedRows: boolean; -} - -type FilePreviewViewMode = "preview" | "source"; -export type TextFilePreviewKind = "csv" | "markdown"; -type FilePreviewToggleKind = "csv" | "html" | "markdown"; -export type FilePreviewHeaderMode = "file" | "none"; -type IframeLoadState = "loading" | "loaded" | "error"; - -const CSV_PREVIEW_MAX_COLUMNS = 100; -const CSV_PREVIEW_MAX_ROWS = 500; - -const FILE_PREVIEW_VIEW_STYLE = { - "--diffs-font-size": "12px", - "--diffs-line-height": "18px", - // Pierre paints its theme bg inside this gap, so the top breathing room of - // the code body lives on Pierre's bg — not on the panel's bg-background. - // Without this, the gap above Pierre would show a visible bg-color seam. - "--diffs-gap-block": "16px", -} as CSSProperties; - -// `--md-content-w` tells MarkdownPreview the surrounding text-column width so -// narrow tables sit flush with the prose on the left instead of centering in -// the panel. `100cqi` resolves against the `@container/page` scope on the -// wrapper below — i.e. the panel width. -const FILE_PREVIEW_WRAPPER_STYLE = { - "--md-content-w": "100cqi", -} as CSSProperties; - -const HTML_FILE_PREVIEW_IFRAME_STYLE = { - width: "100%", - height: "100%", - border: 0, -} as CSSProperties; -const IFRAME_LOADING_INDICATOR_DELAY_MS = 160; -const FILE_PREVIEW_HEADER_ICON_BUTTON_CLASS = - "h-5 w-5 rounded-sm p-0 [&_svg]:size-3 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9 max-md:pointer-coarse:[&_svg]:size-5"; - -function getFilePreviewToggleKind( - state: FilePreviewState, -): FilePreviewToggleKind | null { - if (state.kind === "html") { - return "html"; - } - if (state.kind === "ready") { - return state.textPreviewKind; - } - return null; -} - -function getToggleAriaLabel(kind: FilePreviewToggleKind): string { - switch (kind) { - case "csv": - return "CSV view mode"; - case "html": - return "HTML view mode"; - case "markdown": - return "Markdown view mode"; - } -} - -function getFileContentsCopyLabel(kind: FilePreviewToggleKind | null): string { - if (kind === "csv") { - return "Copy CSV"; - } - if (kind === "markdown") { - return "Copy markdown"; - } - if (kind === "html") { - return "Copy HTML source"; - } - return "Copy file contents"; -} - -function getLineWrapToggleLabel(lineOverflowMode: CodeOverflowMode): string { - return lineOverflowMode === "wrap" ? "Disable line wrap" : "Wrap lines"; -} - -function getFilePreviewLineRange( - state: FilePreviewState, -): FilePreviewLineRange | null { - if (state.kind === "html" || state.kind === "ready") { - return state.lineRange; - } - return null; -} - -function getRawFilePreviewContents(state: FilePreviewState): string | null { - if (state.kind === "html" || state.kind === "ready") { - return state.file.contents; - } - return null; -} - -function getInitialFilePreviewViewMode({ - lineRange, - toggleKind, -}: GetInitialFilePreviewViewModeArgs): FilePreviewViewMode { - if (toggleKind === "csv" || toggleKind === "markdown") { - return "preview"; - } - return lineRange === null ? "preview" : "source"; -} - -function usesCodeViewLayout( - state: FilePreviewState, - viewMode: FilePreviewViewMode, -): boolean { - if (state.kind === "html") { - return viewMode === "source"; - } - - if (state.kind !== "ready") { - return false; - } - - return state.textPreviewKind === null || viewMode === "source"; -} - -interface ParsedCsvRows { - rows: string[][]; - truncatedRows: boolean; -} - -// Stops scanning once `maxRows` rows are collected, so a multi-megabyte CSV -// only pays for the previewed prefix. -function parseCsvRows(contents: string, maxRows: number): ParsedCsvRows { - const rows: string[][] = []; - let row: string[] = []; - let field = ""; - let inQuotes = false; - let quotedField = false; - let endedWithLineBreak = false; - - for (let index = 0; index < contents.length; index += 1) { - const character = contents[index]; - endedWithLineBreak = false; - - if (inQuotes) { - if (character === '"') { - if (contents[index + 1] === '"') { - field += '"'; - index += 1; - } else { - inQuotes = false; - } - } else { - field += character; - } - continue; - } - - if (character === '"' && field.length === 0) { - inQuotes = true; - quotedField = true; - continue; - } - - if (character === ",") { - row.push(field); - field = ""; - quotedField = false; - continue; - } - - if (character === "\n" || character === "\r") { - row.push(field); - rows.push(row); - row = []; - field = ""; - quotedField = false; - endedWithLineBreak = true; - if (character === "\r" && contents[index + 1] === "\n") { - index += 1; - } - if (rows.length >= maxRows) { - return { rows, truncatedRows: index + 1 < contents.length }; - } - continue; - } - - field += character; - } - - if ( - field.length > 0 || - row.length > 0 || - quotedField || - !endedWithLineBreak - ) { - row.push(field); - rows.push(row); - } - - return { rows, truncatedRows: false }; -} - -export function buildCsvPreviewData(contents: string): CsvPreviewData { - // +1: the first parsed row is the header, so the cap counts data rows. - const { rows, truncatedRows } = parseCsvRows( - contents, - CSV_PREVIEW_MAX_ROWS + 1, - ); - // Column stats only consider the previewed rows; a wider row past the row - // cap won't flag truncatedColumns. Fine for a preview. - const columnCount = rows.reduce( - (maximum, row) => Math.max(maximum, row.length), - 0, - ); - - return { - columnCount: Math.min(columnCount, CSV_PREVIEW_MAX_COLUMNS), - rows, - truncatedColumns: columnCount > CSV_PREVIEW_MAX_COLUMNS, - truncatedRows, - }; -} - -export function getCsvTruncationNote( - preview: CsvPreviewData, - dataRowCount: number, -): string | null { - const limits: string[] = []; - if (preview.truncatedRows) { - limits.push(`${dataRowCount.toLocaleString()} rows`); - } - if (preview.truncatedColumns) { - limits.push(`${preview.columnCount.toLocaleString()} columns`); - } - if (limits.length === 0) { - return null; - } - return `Showing the first ${limits.join(" and ")}.`; -} - -export function FilePreview({ - state, - path, - copyPath = null, - headerMode = "file", - onSelectionAddToChat, - onOpenInEditor, - onRefresh, - isRefreshing = false, - markdownLinkRouting, - statusLabel = null, -}: FilePreviewProps) { - const toggleKind = getFilePreviewToggleKind(state); - const filePreviewLineRange = getFilePreviewLineRange(state); - const rawContents = getRawFilePreviewContents(state); - const [viewMode, setViewMode] = useState( - getInitialFilePreviewViewMode({ - lineRange: filePreviewLineRange, - toggleKind, - }), - ); - const [lineOverflowMode, setLineOverflowMode] = useState( - DEFAULT_CODE_OVERFLOW_MODE, - ); - // Each new file opens in the appropriate default mode; the user re-toggles - // per file rather than carrying their last choice across unrelated files. - useEffect(() => { - setViewMode( - getInitialFilePreviewViewMode({ - lineRange: filePreviewLineRange, - toggleKind, - }), - ); - }, [filePreviewLineRange, path, toggleKind]); - - const usesIframeLayout = - state.kind === "iframe" || - (state.kind === "html" && viewMode === "preview"); - const bodyViewMode: FilePreviewViewMode = - toggleKind === null ? "preview" : viewMode; - const usesCodeLayout = usesCodeViewLayout(state, bodyViewMode); - const showLineOverflowToggle = usesCodeLayout; - // The markdown preview renders on a raised "paper" surface that should fill - // the panel to the bottom even for short documents. `min-h-full` (vs the - // iframe layout's `h-full min-h-0`) keeps the column growable, so long - // documents still scroll the outer panel rather than an inner box. - const usesMarkdownPreviewLayout = - state.kind === "ready" && - state.textPreviewKind === "markdown" && - bodyViewMode === "preview"; - // The CSV table needs one scroller that owns both axes: its sticky header - // row and row-number gutter only stick against their own scrollport, and - // splitting the axes (panel scrolls vertically, inner box horizontally) - // strands the horizontal scrollbar at the bottom of the full-height table - // and lets the sticky gutter paint over the panel header. So fill the panel - // like the iframe layout and let CsvFilePreview scroll internally. - const usesCsvPreviewLayout = - state.kind === "ready" && - state.textPreviewKind === "csv" && - bodyViewMode === "preview"; - const usesFullHeightLayout = usesIframeLayout || usesCsvPreviewLayout; - const usesContentHeightLayout = usesCodeLayout || usesMarkdownPreviewLayout; - - // Establish a `@container/page` scope so MarkdownPreview's `100cqw`-based - // table breakout sizes against this panel, not the viewport. - return ( -
- {headerMode === "file" ? ( - - ) : null} - -
- ); -} - -function FilePreviewBody({ - state, - path, - lineOverflowMode, - viewMode, - markdownLinkRouting, - onSelectionAddToChat, -}: FilePreviewBodyProps) { - if (state.kind === "loading") { - return ; - } - if (state.kind === "empty") { - return ; - } - if (state.kind === "not-found") { - return ; - } - if (state.kind === "error") { - return ( - - ); - } - if (state.kind === "image") { - return ; - } - if (state.kind === "video") { - return ; - } - if (state.kind === "iframe") { - return ( - - ); - } - if (state.kind === "html") { - return ( - - ); - } - if (state.textPreviewKind === "csv" && viewMode === "preview") { - return ( - - ); - } - if (state.textPreviewKind === "markdown" && viewMode === "preview") { - return ( - - ); - } - return ( - - ); -} - -function FilePreviewHeader({ - path, - copyPath, - rawContents, - onOpenInEditor, - onRefresh, - isRefreshing, - statusLabel, - toggleKind, - showLineOverflowToggle, - lineOverflowMode, - onLineOverflowModeChange, - viewMode, - onViewModeChange, -}: FilePreviewHeaderProps) { - const openShortcut = useAppCommandShortcut("workspace.openPreferred"); - const showHeaderControls = showLineOverflowToggle || toggleKind !== null; - const copyFileContentsLabel = getFileContentsCopyLabel(toggleKind); - - return ( - // The wrapper carries an opaque panel-surface base so the translucent - // `bg-surface-recessed` tint on the bar composites to a solid tone — without - // it, body content scrolling under the sticky header would bleed through. -
-
-
- - - {statusLabel === null ? null : ( - - ({statusLabel}) - - )} - - {onRefresh ? ( - - - - - - {isRefreshing ? "Refreshing file" : "Refresh file"} - - - ) : null} - {rawContents === null ? null : ( - - - - - - {copyFileContentsLabel} - - - )} - {onOpenInEditor ? ( - <> - - - onOpenInEditor(path)} - label={ - openShortcut - ? `Open in editor (${openShortcut.label})` - : "Open in editor" - } - aria-keyshortcuts={openShortcut?.ariaKeyshortcuts} - /> - - - {openShortcut - ? `Open in editor (${openShortcut.label})` - : "Open in editor"} - - - - - ) : null} - +import { lazy, Suspense } from "react"; +import type { FilePreviewProps } from "./FilePreviewImpl"; + +const FilePreviewImpl = lazy(() => + import("./FilePreviewImpl").then((module) => ({ + default: module.FilePreview, + })), +); + +export function FilePreview(props: FilePreviewProps) { + return ( + + Loading file preview
- {showHeaderControls ? ( -
- - {toggleKind !== null ? ( -
- - -
- ) : null} -
- ) : null} -
-
- ); -} - -function FilePreviewPath({ path, copyPath }: FilePreviewPathProps) { - const copyTarget = copyPath ?? path; - const label = "Copy file path"; - const className = cn( - "min-w-0 font-mono font-medium leading-5 text-file-accent", - COARSE_POINTER_TEXT_SM_CLASS, - ); - - return ( - - - - - - {label} - - - ); -} - -function FilePreviewLineWrapButton({ - showLineOverflowToggle, - lineOverflowMode, - onLineOverflowModeChange, -}: FilePreviewLineWrapButtonProps) { - if (!showLineOverflowToggle) { - return null; - } - - const label = getLineWrapToggleLabel(lineOverflowMode); - - return ( - - - - - - {label} - - - ); -} - -function HtmlFilePreviewBody({ - lineOverflowMode, - onSelectionAddToChat, - state, - viewMode, -}: HtmlFilePreviewBodyProps) { - const isPreviewVisible = viewMode === "preview"; - return ( - <> -
- -
-
- -
- - ); -} - -function MarkdownFilePreview({ - file, - onSelectionAddToChat, - urlTransform, - markdownLinkRouting, -}: MarkdownFilePreviewProps) { - return ( - // Keep rendered Markdown on the ordinary document background. Its parent - // owns the boundary, so another raised "paper" layer would make nested - // file viewers feel like cards stacked inside cards. - -
- -
-
- ); -} - -function CsvFilePreview({ file, onSelectionAddToChat }: CsvFilePreviewProps) { - const preview = useMemo( - () => buildCsvPreviewData(file.contents), - [file.contents], - ); - const headerRow = preview.rows[0] ?? []; - const bodyRows = preview.rows.slice(1); - const columns = Array.from({ length: preview.columnCount }, (_, index) => ({ - index, - label: headerRow[index] ?? "", - })); - const tableWidth = `max(100%, ${3 + columns.length * 18}rem)`; - const truncationNote = getCsvTruncationNote(preview, bodyRows.length); - - return ( - - {/* Single scroll container for both axes: the sticky header row and - row-number gutter stick against this box, the horizontal scrollbar - stays visible at the panel bottom, and the sticky cells are clipped - here so they can't paint over the panel header. */} -
- {/* overscroll-contain: panning a wide table past its edge must not - chain into the browser back/forward gesture (kept alive globally — - see app.css overscroll notes) or scroll an ancestor. */} -
- - - - {columns.map((column) => ( - - ))} - - - - - {columns.map((column) => ( - - ))} - - - - {bodyRows.map((row, rowIndex) => ( - - - {columns.map((column) => { - const cell = row[column.index] ?? ""; - return ( - - ); - })} - - ))} - -
- # - - - {column.label || `Column ${column.index + 1}`} - -
- {rowIndex + 2} - - - {cell} - -
-
- {truncationNote === null ? null : ( -

- {truncationNote} -

- )} -
-
- ); -} - -function FilePreviewImage({ url, alt }: FilePreviewImageProps) { - return ( -
- {alt} -
- ); -} - -function FilePreviewVideo({ url, title }: FilePreviewVideoProps) { - return ( -
-
- ); -} - -function IframeFilePreview({ sandbox, title, url }: IframeFilePreviewTarget) { - const [loadState, setLoadState] = useState("loading"); - const [showLoadingIndicator, setShowLoadingIndicator] = useState(false); - - useEffect(() => { - setLoadState("loading"); - }, [url]); - - useEffect(() => { - if (loadState !== "loading") { - setShowLoadingIndicator(false); - return; - } - - setShowLoadingIndicator(false); - const timeoutId = window.setTimeout(() => { - setShowLoadingIndicator(true); - }, IFRAME_LOADING_INDICATOR_DELAY_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [loadState, url]); - - if (loadState === "error") { - return ( -
- -
- ); - } - - return ( -
- {loadState === "loading" && showLoadingIndicator ? ( -
- -
- ) : null} -