Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions apps/app/bundle-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@
"painted' and 'route content painted'. SplitWorkspaceRoute is every thread,",
"compose and plugin-panel page, so its closure is the second number that",
"decides how slow bb feels on a phone. Same 10% ratchet; its forbiddenPackages",
"are the diff engine, math, file tree and terminal code that only a user",
"action needs.",
"The composer (tiptap/prosemirror) is visible on every thread page and is",
"allowed until it moves behind a first-focus handoff. Run",
"are the diff engine, math, file tree, terminal and editor code that only a",
"user action needs.",
"The composer (tiptap/prosemirror) loads behind PromptBox's first-focus",
"handoff: the shell chrome is eager, the editor graph is gated on",
"PromptBoxInternal (see onDemandPackages). Run",
"`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx <package>`",
"to print the static chain that pulled a package into the closure."
],
Expand Down Expand Up @@ -72,13 +73,45 @@
],
"onDemandPackages": {
"@pierre/trees": "src/components/secondary-panel/ThreadStorageFileTree.tsx",
"@tiptap/core": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-blockquote": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-bold": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-code": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-code-block": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-document": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-hard-break": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-heading": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-horizontal-rule": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-italic": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-link": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-list": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-mention": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-paragraph": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-placeholder": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-strike": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-text": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extension-underline": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/extensions": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/react": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/starter-kit": "src/components/promptbox/PromptBoxInternal.tsx",
"@tiptap/suggestion": "src/components/promptbox/PromptBoxInternal.tsx",
"katex": "src/components/ui/markdown-katex.ts",
"prosemirror-commands": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-dropcursor": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-gapcursor": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-history": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-keymap": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-model": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-schema-list": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-state": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-transform": "src/components/promptbox/PromptBoxInternal.tsx",
"prosemirror-view": "src/components/promptbox/PromptBoxInternal.tsx",
"rehype-katex": "src/components/ui/markdown-katex.ts"
},
"routeClosures": {
"SplitWorkspaceRoute": {
"maxBytes": 2268430,
"maxBrotliBytes": 605332,
"maxBytes": 1765479,
"maxBrotliBytes": 489779,
"forbiddenPackages": [
"@pierre/diffs",
"@pierre/theming",
Expand All @@ -88,10 +121,17 @@
"@shikijs/engine-oniguruma",
"@shikijs/langs",
"@shikijs/vscode-textmate",
"@tiptap/core",
"@tiptap/pm",
"@tiptap/react",
"@tiptap/starter-kit",
"@xterm/xterm",
"katex",
"mermaid",
"oniguruma-to-es",
"prosemirror-model",
"prosemirror-state",
"prosemirror-view",
"rehype-katex",
"shiki"
]
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ vi.mock("@bb/shared-ui/hooks/use-pointer-coarse", () => ({
usePointerCoarse: () => mocks.isPointerCoarse,
}));

vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
PromptBoxInternal: ({
vi.mock("@/components/promptbox/PromptBox", () => ({
PromptBox: ({
footerStart,
compact,
onSubmit,
Expand Down
10 changes: 4 additions & 6 deletions apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ import {
useComposerExtensionController,
} from "@/components/plugin/ComposerExtensionHost";
import {
PromptBoxInternal,
PromptBox,
type AttachmentsConfig,
type HistoryConfig,
type PromptBoxAction,
type PromptBoxHandle,
type TypeaheadConfig,
} from "@/components/promptbox/PromptBoxInternal";
} from "@/components/promptbox/PromptBox";
import { usePromptVoice } from "@/components/promptbox/usePromptVoice";
import { PermissionModePicker } from "@/components/pickers/PermissionModePicker";
import {
Expand All @@ -61,9 +61,7 @@ import {
shouldDisablePermissionPickerForActivePromptMode,
} from "@bb/client-core";

type PromptBoxWithScrollAnchorProps = ComponentProps<
typeof PromptBoxInternal
> & {
type PromptBoxWithScrollAnchorProps = ComponentProps<typeof PromptBox> & {
scrollToBottomOnModifierSubmit?: boolean;
scrollToBottomOnSubmit?: boolean;
};
Expand Down Expand Up @@ -101,7 +99,7 @@ function PromptBoxWithScrollAnchor({
: {}),
};
return (
<PromptBoxInternal
<PromptBox
{...promptBoxProps}
onSubmit={handleSubmit}
submission={anchoredSubmission}
Expand Down
8 changes: 4 additions & 4 deletions apps/app/src/components/promptbox/NewThreadPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ import {
type ExecutionPermissionConfig,
} from "@/components/promptbox/ExecutionControls";
import {
PromptBoxInternal,
PromptBox,
type AttachmentsConfig,
type HistoryConfig,
type PromptBoxAction,
type PromptBoxHandle,
type TypeaheadConfig,
} from "@/components/promptbox/PromptBoxInternal";
} from "@/components/promptbox/PromptBox";
import { usePromptVoice } from "@/components/promptbox/usePromptVoice";
import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext";
import {
Expand Down Expand Up @@ -164,7 +164,7 @@ export interface NewThreadModeConfig {
}

interface NewThreadPromptBoxUIProps {
/** id forwarded to the underlying PromptBoxInternal (used for autofocus targeting). */
/** id forwarded to the underlying PromptBox (used for autofocus targeting). */
id?: string;

// PromptBox passthrough
Expand Down Expand Up @@ -390,7 +390,7 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({
{modeConfig.banner}
</ComposerBannersSlot>
</div>
<PromptBoxInternal
<PromptBox
id={id}
promptBoxRef={promptBoxRef}
value={value}
Expand Down
223 changes: 223 additions & 0 deletions apps/app/src/components/promptbox/PromptBox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// @vitest-environment jsdom

import type { PromptTextMention } from "@bb/domain";
import { createRef, useState, type RefObject } from "react";
import {
act,
cleanup,
fireEvent,
render,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PromptBox } from "./PromptBox";
import {
INERT_TYPEAHEAD_COMMAND_CONFIG,
type PromptBoxHandle,
type TypeaheadConfig,
} from "./PromptBoxInternal";

/**
* First-focus handoff coverage (plan 008): the shell renders without the
* tiptap editor; the first tap/paste/focus request realizes the editor and
* every byte of input captured before the mount survives into it.
*/

function makeTypeahead(): TypeaheadConfig {
return {
mention: {
suggestions: [],
isLoading: false,
isError: false,
onQueryChange: vi.fn(),
},
command: INERT_TYPEAHEAD_COMMAND_CONFIG,
};
}

interface HarnessProps {
initialValue?: string;
autoFocus?: boolean;
focusEndKey?: string | number;
onSubmit?: () => void;
onChangeSpy?: (value: string, mentions: PromptTextMention[]) => void;
onAttachFiles?: (files: File[]) => void;
promptBoxRef?: RefObject<PromptBoxHandle | null>;
}

function ControlledPromptBox({
initialValue = "",
autoFocus = false,
focusEndKey,
onSubmit = () => {},
onChangeSpy,
onAttachFiles,
promptBoxRef,
}: HarnessProps) {
const [draft, setDraft] = useState<{
value: string;
mentions: PromptTextMention[];
}>({ value: initialValue, mentions: [] });
return (
<PromptBox
value={draft.value}
mentionRanges={draft.mentions}
onChange={(value, mentions) => {
onChangeSpy?.(value, mentions);
setDraft({ value, mentions });
}}
onSubmit={onSubmit}
autoFocus={autoFocus}
mentionMenuPlacement="bottom"
typeahead={makeTypeahead()}
{...(onAttachFiles ? { attachments: { items: [], onAttachFiles } } : {})}
{...(promptBoxRef ? { promptBoxRef } : {})}
{...(focusEndKey !== undefined ? { focusEndKey } : {})}
/>
);
}

function queryMountedEditor(): HTMLElement | null {
// The shell's preview is a contenteditable="false" .ProseMirror stand-in;
// only the real tiptap editor is contenteditable="true".
return document.querySelector<HTMLElement>(
'.ProseMirror[contenteditable="true"]',
);
}

function getInterimInput(): HTMLTextAreaElement {
const interim = document.querySelector<HTMLTextAreaElement>(
"[data-promptbox-interim-input]",
);
if (!interim) throw new Error("interim input not rendered");
return interim;
}

async function waitForMountedEditor(): Promise<HTMLElement> {
await waitFor(() => {
expect(queryMountedEditor()).not.toBeNull();
});
const editor = queryMountedEditor();
if (!editor) throw new Error("editor did not mount");
return editor;
}

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe("PromptBox first-focus handoff", () => {
it("parks the composer as a shell and mounts the editor on first tap with the draft intact", async () => {
render(<ControlledPromptBox initialValue="Saved draft" />);

// Shell state: the saved draft is visible immediately, no editor mounted.
const preview = document.querySelector("[data-promptbox-shell-preview]");
expect(preview).not.toBeNull();
expect(preview?.textContent).toContain("Saved draft");
expect(queryMountedEditor()).toBeNull();

fireEvent.focus(getInterimInput());

const editor = await waitForMountedEditor();
expect(editor.textContent).toContain("Saved draft");
expect(
document.querySelector("[data-promptbox-shell-preview]"),
).toBeNull();
});

it("streams keystrokes typed before the mount into the draft and the editor", async () => {
const onChangeSpy = vi.fn();
render(<ControlledPromptBox onChangeSpy={onChangeSpy} />);

const interim = getInterimInput();
fireEvent.focus(interim);
fireEvent.input(interim, { target: { value: "hi there" } });

// The buffer flushes into the controlled draft on every input event.
expect(onChangeSpy).toHaveBeenLastCalledWith("hi there", []);

const editor = await waitForMountedEditor();
expect(editor.textContent).toContain("hi there");
});

it("lands a text paste made before the mount in the editor after the mount", async () => {
const onChangeSpy = vi.fn();
render(<ControlledPromptBox onChangeSpy={onChangeSpy} />);

const interim = getInterimInput();
fireEvent.focus(interim);
fireEvent.paste(interim, {
clipboardData: {
items: [],
getData: (type: string) =>
type === "text/plain" ? "pasted\r\nbefore mount" : "",
},
});

expect(onChangeSpy).toHaveBeenLastCalledWith("pasted\nbefore mount", []);

const editor = await waitForMountedEditor();
expect(editor.textContent).toContain("pasted");
expect(editor.textContent).toContain("before mount");
});

it("routes a file paste made before the mount to the attachments handler", () => {
const onAttachFiles = vi.fn();
render(<ControlledPromptBox onAttachFiles={onAttachFiles} />);

const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
const interim = getInterimInput();
fireEvent.focus(interim);
fireEvent.paste(interim, {
clipboardData: {
items: [{ kind: "file", getAsFile: () => file }],
getData: () => "",
},
});

expect(onAttachFiles).toHaveBeenCalledWith([file]);
});

it("realizes the editor and moves focus into it for a programmatic focus request", async () => {
const promptBoxRef = createRef<PromptBoxHandle | null>();
render(<ControlledPromptBox promptBoxRef={promptBoxRef} />);
expect(queryMountedEditor()).toBeNull();

act(() => {
promptBoxRef.current?.focusEnd();
});

const editor = await waitForMountedEditor();
await waitFor(() => {
expect(document.activeElement).toBe(editor);
});
});

it("realizes the editor when focusEndKey changes (the thread view's focus bus)", async () => {
const { rerender } = render(<ControlledPromptBox focusEndKey={0} />);
expect(queryMountedEditor()).toBeNull();

rerender(<ControlledPromptBox focusEndKey={1} />);

await waitForMountedEditor();
});

it("realizes the editor on mount when autoFocus applies (fine pointer)", async () => {
render(<ControlledPromptBox autoFocus />);

await waitForMountedEditor();
});

it("submits on Enter from the interim surface before the editor exists", () => {
const onSubmit = vi.fn();
render(<ControlledPromptBox onSubmit={onSubmit} />);

const interim = getInterimInput();
fireEvent.focus(interim);
fireEvent.input(interim, { target: { value: "ship it" } });
fireEvent.keyDown(interim, { key: "Enter" });

expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
Loading
Loading