diff --git a/apps/app/src/components/thread/embedded-chat/index.ts b/apps/app/src/components/thread/embedded-chat/index.ts index 4a4cca8000..aeb82fd48a 100644 --- a/apps/app/src/components/thread/embedded-chat/index.ts +++ b/apps/app/src/components/thread/embedded-chat/index.ts @@ -10,7 +10,10 @@ export { useInlineQueuedMessageEditing, type InlineQueuedMessageEditState, } from "./useInlineQueuedMessageEditing"; -export { useActiveComposerDraft } from "./useActiveComposerDraft"; +export { + useActiveComposerDraft, + useActiveComposerDraftWriters, +} from "./useActiveComposerDraft"; export { useComposerAttachmentUploads, useDraftAttachmentUploads, diff --git a/apps/app/src/components/thread/embedded-chat/useActiveComposerDraft.ts b/apps/app/src/components/thread/embedded-chat/useActiveComposerDraft.ts index d0c4296c3a..80fc1c1f41 100644 --- a/apps/app/src/components/thread/embedded-chat/useActiveComposerDraft.ts +++ b/apps/app/src/components/thread/embedded-chat/useActiveComposerDraft.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react"; import type { PromptTextMention } from "@bb/domain"; import { usePromptDraftStorage, + type PromptDraftAccessor, type PromptDraftScope, } from "@/hooks/usePromptDraftStorage"; import { promptDraftToInput } from "@/lib/prompt-draft"; @@ -31,41 +32,38 @@ export interface UseActiveComposerDraftResult { removeActiveComposerAttachment: (path: string) => void; } +interface UseActiveComposerDraftWritersArgs { + inlineEditingQueuedMessageRef: React.RefObject; + commitInlineQueuedMessage: ( + next: InlineQueuedMessageEditState | null, + ) => void; + storedDraft: Pick< + PromptDraftAccessor, + "setDraft" | "setTextAndMentions" | "removeAttachment" + >; +} + +export interface ActiveComposerDraftWriters { + setActiveComposerDraft: (draft: PromptDraftState) => void; + handleChangeMessage: (text: string, mentions: PromptTextMention[]) => void; + removeActiveComposerAttachment: (path: string) => void; +} + /** - * Exposes the persisted bottom draft plus an active draft view for the inline - * queued-message editor and the currently published plugin host. Active writes - * route through the inline-edit ref so back-to-back plugin composer actions in - * one event observe each other's updates. + * Writers for "whichever draft is active": the inline queued-message edit + * while one is open, otherwise the stored bottom draft. Reads the inline edit + * through its ref so back-to-back plugin composer actions in one event observe + * each other's updates. Needs no draft subscription, so a caller that must not + * re-render per keystroke can use it with `usePromptDraftAccessor`. */ -export function useActiveComposerDraft({ - draftScope, - inlineEditingQueuedMessage, +export function useActiveComposerDraftWriters({ inlineEditingQueuedMessageRef, commitInlineQueuedMessage, -}: UseActiveComposerDraftArgs): UseActiveComposerDraftResult { - const promptDraft = usePromptDraftStorage(draftScope); - const setStoredPromptDraft = promptDraft.setDraft; - const setStoredPromptTextAndMentions = promptDraft.setTextAndMentions; - const removeStoredPromptAttachment = promptDraft.removeAttachment; - - const currentPromptDraft = useMemo( - () => ({ - text: promptDraft.text, - mentions: promptDraft.mentions, - attachments: promptDraft.attachments, - }), - [promptDraft.attachments, promptDraft.mentions, promptDraft.text], - ); - const currentPromptDraftInput = useMemo( - () => promptDraftToInput(currentPromptDraft), - [currentPromptDraft], - ); - const activeComposerDraft = - inlineEditingQueuedMessage?.draft ?? currentPromptDraft; - const activeComposerDraftInput = useMemo( - () => promptDraftToInput(activeComposerDraft), - [activeComposerDraft], - ); + storedDraft, +}: UseActiveComposerDraftWritersArgs): ActiveComposerDraftWriters { + const setStoredPromptDraft = storedDraft.setDraft; + const setStoredPromptTextAndMentions = storedDraft.setTextAndMentions; + const removeStoredPromptAttachment = storedDraft.removeAttachment; const setActiveComposerDraft = useCallback( (draft: PromptDraftState) => { @@ -124,6 +122,55 @@ export function useActiveComposerDraft({ ], ); + return { + setActiveComposerDraft, + handleChangeMessage, + removeActiveComposerAttachment, + }; +} + +/** + * Exposes the persisted bottom draft plus an active draft view for the inline + * queued-message editor and the currently published plugin host. Active writes + * route through the inline-edit ref so back-to-back plugin composer actions in + * one event observe each other's updates. + */ +export function useActiveComposerDraft({ + draftScope, + inlineEditingQueuedMessage, + inlineEditingQueuedMessageRef, + commitInlineQueuedMessage, +}: UseActiveComposerDraftArgs): UseActiveComposerDraftResult { + const promptDraft = usePromptDraftStorage(draftScope); + + const currentPromptDraft = useMemo( + () => ({ + text: promptDraft.text, + mentions: promptDraft.mentions, + attachments: promptDraft.attachments, + }), + [promptDraft.attachments, promptDraft.mentions, promptDraft.text], + ); + const currentPromptDraftInput = useMemo( + () => promptDraftToInput(currentPromptDraft), + [currentPromptDraft], + ); + const activeComposerDraft = + inlineEditingQueuedMessage?.draft ?? currentPromptDraft; + const activeComposerDraftInput = useMemo( + () => promptDraftToInput(activeComposerDraft), + [activeComposerDraft], + ); + const { + setActiveComposerDraft, + handleChangeMessage, + removeActiveComposerAttachment, + } = useActiveComposerDraftWriters({ + inlineEditingQueuedMessageRef, + commitInlineQueuedMessage, + storedDraft: promptDraft, + }); + return { promptDraft, currentPromptDraft, diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index 01dc987869..8944656ccc 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -8,6 +8,7 @@ import { appendQuoteAndAttachmentsToDraft, arePromptDraftStatesEqual, emptyPromptDraftState, + hasPromptDraftSubmittableInput, isPromptDraftEmpty, parsePromptDraftStorage, serializePromptDraftStorage, @@ -280,73 +281,50 @@ function getPromptDraftStorageKey(scope: PromptDraftScope): string { } /** - * Imperative access to a scope's stored draft without subscribing to it. - * - * For components that only need to read or replace the draft at event time - * (e.g. the browse hero seeding the composer, or a thread view's "Add to - * chat" quote action): `usePromptDraftStorage` is a `useSyncExternalStore` - * subscription, so it re-renders its caller on every keystroke a mounted - * composer writes — pure waste when the caller never renders the draft, and - * actively harmful when the caller is a large tree like the thread timeline. + * Imperative handle on a scope's stored draft. Every method reads the store at + * call time, so the object is safe to hold across renders and share with + * event handlers; only `usePromptDraftStorage` adds the reactive draft value. */ -export function getPromptDraftAccessor(scope: PromptDraftScope): { +export interface PromptDraftAccessor { storageKey: string; getCurrent: () => PromptDraftState; setDraft: (draft: PromptDraftState) => void; + setTextAndMentions: (text: string, mentions: PromptTextMention[]) => void; + setAttachments: (attachments: PromptDraftAttachment[]) => void; + addAttachment: (attachment: PromptDraftAttachment) => void; + removeAttachment: (path: string) => void; addQuote: ( text: string, attachments?: readonly PromptDraftAttachment[], ) => void; -} { - const storageKey = getPromptDraftStorageKey(scope); + clear: () => void; + /** Clears the draft only when it still equals `expectedDraft`; returns whether it did. */ + clearIfCurrentMatches: (expectedDraft: PromptDraftState) => boolean; + restoreIfEmpty: (draft: PromptDraftState) => void; +} + +function createPromptDraftAccessor(storageKey: string): PromptDraftAccessor { + const setDraft = (draft: PromptDraftState) => { + writePromptDraft(storageKey, draft); + }; return { storageKey, getCurrent: () => readPromptDraft(storageKey), - setDraft: (draft) => writePromptDraft(storageKey, draft), - addQuote: (text, attachments) => - addQuoteToPromptDraft(storageKey, text, attachments), - }; -} - -export function usePromptDraftStorage(scope: PromptDraftScope) { - const storageKey = getPromptDraftStorageKey(scope); - const draft = useSyncExternalStore( - useCallback( - (listener) => subscribePromptDraft(storageKey, listener), - [storageKey], - ), - useCallback(() => readPromptDraft(storageKey), [storageKey]), - () => EMPTY_PROMPT_DRAFT, - ); - - const setDraftAndPersist = useCallback( - (nextDraft: PromptDraftState) => { - writePromptDraft(storageKey, nextDraft); - }, - [storageKey], - ); - - const getCurrent = useCallback((): PromptDraftState => { - return readPromptDraft(storageKey); - }, [storageKey]); - - const setTextAndMentions = useCallback( - (nextText: string, nextMentions: PromptTextMention[]) => { + setDraft, + setTextAndMentions: (text, mentions) => { writePromptDraft( storageKey, - { - ...readPromptDraft(storageKey), - text: nextText, - mentions: nextMentions, - }, + { ...readPromptDraft(storageKey), text, mentions }, { persist: "deferred" }, ); }, - [storageKey], - ); - - const addAttachment = useCallback( - (attachment: PromptDraftAttachment) => { + setAttachments: (attachments) => { + writePromptDraft(storageKey, { + ...readPromptDraft(storageKey), + attachments, + }); + }, + addAttachment: (attachment) => { const currentDraft = readPromptDraft(storageKey); const alreadyExists = currentDraft.attachments.some( (existingAttachment) => existingAttachment.path === attachment.path, @@ -358,11 +336,7 @@ export function usePromptDraftStorage(scope: PromptDraftScope) { attachments: [...currentDraft.attachments, attachment], }); }, - [storageKey], - ); - - const removeAttachment = useCallback( - (path: string) => { + removeAttachment: (path) => { const currentDraft = readPromptDraft(storageKey); const nextAttachments = currentDraft.attachments.filter( (attachment) => attachment.path !== path, @@ -376,84 +350,75 @@ export function usePromptDraftStorage(scope: PromptDraftScope) { attachments: nextAttachments, }); }, - [storageKey], - ); - - const addQuote = useCallback( - (text: string, attachments?: readonly PromptDraftAttachment[]) => + addQuote: (text, attachments) => addQuoteToPromptDraft(storageKey, text, attachments), - [storageKey], - ); - - const clear = useCallback(() => { - setDraftAndPersist(EMPTY_PROMPT_DRAFT); - }, [setDraftAndPersist]); - - const clearIfCurrentMatches = useCallback( - (expectedDraft: PromptDraftState): boolean => { + clear: () => setDraft(EMPTY_PROMPT_DRAFT), + clearIfCurrentMatches: (expectedDraft) => { if ( !arePromptDraftStatesEqual(readPromptDraft(storageKey), expectedDraft) ) { return false; } - setDraftAndPersist(EMPTY_PROMPT_DRAFT); + setDraft(EMPTY_PROMPT_DRAFT); return true; }, - [setDraftAndPersist, storageKey], - ); - - const setAttachments = useCallback( - (attachments: PromptDraftAttachment[]) => { - writePromptDraft(storageKey, { - ...readPromptDraft(storageKey), - attachments, - }); + restoreIfEmpty: (draft) => { + restorePromptDraftIfEmpty(storageKey, draft); }, - [storageKey], - ); + }; +} - const restoreIfEmpty = useCallback( - (nextDraft: PromptDraftState) => { - restorePromptDraftIfEmpty(storageKey, nextDraft); - }, - [storageKey], +/** + * Imperative access to a scope's stored draft without subscribing to it. + * + * For components that only need to read or replace the draft at event time + * (e.g. the browse hero seeding the composer, or a thread view's "Add to + * chat" quote action): `usePromptDraftStorage` is a `useSyncExternalStore` + * subscription, so it re-renders its caller on every keystroke a mounted + * composer writes — pure waste when the caller never renders the draft, and + * actively harmful when the caller is a large tree like the thread timeline. + */ +export function getPromptDraftAccessor( + scope: PromptDraftScope, +): PromptDraftAccessor { + return createPromptDraftAccessor(getPromptDraftStorageKey(scope)); +} + +/** + * `getPromptDraftAccessor` with a stable identity per storage key, for + * components that hand the accessor's methods to memoized children or hook + * dependency lists (e.g. `ThreadDetailPromptArea`, which must not re-render + * per keystroke but still submits, clears and restores the draft). + */ +export function usePromptDraftAccessor( + scope: PromptDraftScope, +): PromptDraftAccessor { + const storageKey = getPromptDraftStorageKey(scope); + return useMemo(() => createPromptDraftAccessor(storageKey), [storageKey]); +} + +export function usePromptDraftStorage(scope: PromptDraftScope) { + const accessor = usePromptDraftAccessor(scope); + const storageKey = accessor.storageKey; + const draft = useSyncExternalStore( + useCallback( + (listener) => subscribePromptDraft(storageKey, listener), + [storageKey], + ), + useCallback(() => readPromptDraft(storageKey), [storageKey]), + () => EMPTY_PROMPT_DRAFT, ); return useMemo( () => ({ - storageKey, - getCurrent, + ...accessor, value: draft.text, text: draft.text, mentions: draft.mentions, attachments: draft.attachments, - setDraft: setDraftAndPersist, - setTextAndMentions, - setAttachments, - addAttachment, - removeAttachment, - addQuote, - clear, - clearIfCurrentMatches, - restoreIfEmpty, }), - [ - addAttachment, - addQuote, - clear, - clearIfCurrentMatches, - draft.attachments, - draft.mentions, - draft.text, - getCurrent, - removeAttachment, - restoreIfEmpty, - setAttachments, - setDraftAndPersist, - setTextAndMentions, - storageKey, - ], + [accessor, draft], ); } @@ -473,6 +438,31 @@ export function usePromptDraftHasInput(scope: PromptDraftScope): boolean { ); } +/** + * True while the scope's draft would submit as non-empty prompt input + * (`promptDraftToInput(draft).length > 0`): trimmed text or at least one + * attachment. Differs from `usePromptDraftHasInput`, which also counts + * whitespace-only text as an unsubmitted draft. Re-renders the caller only + * when the bit flips, not per keystroke. + */ +export function usePromptDraftHasSubmittableInput( + scope: PromptDraftScope, +): boolean { + const storageKey = getPromptDraftStorageKey(scope); + + return useSyncExternalStore( + useCallback( + (listener) => subscribePromptDraft(storageKey, listener), + [storageKey], + ), + useCallback( + () => hasPromptDraftSubmittableInput(readPromptDraft(storageKey)), + [storageKey], + ), + () => false, + ); +} + export interface PromptDraftThreadRef { id: string; projectId: string; diff --git a/apps/app/src/lib/prompt-draft.ts b/apps/app/src/lib/prompt-draft.ts index 66419d407d..1d426ad333 100644 --- a/apps/app/src/lib/prompt-draft.ts +++ b/apps/app/src/lib/prompt-draft.ts @@ -135,6 +135,16 @@ export function isPromptDraftEmpty(draft: PromptDraftState): boolean { ); } +/** + * Whether `promptDraftToInput(draft)` would produce at least one input chunk: + * trimmed text or an attachment. Whitespace-only text is not submittable. + */ +export function hasPromptDraftSubmittableInput( + draft: PromptDraftState, +): boolean { + return draft.text.trim().length > 0 || draft.attachments.length > 0; +} + export function parsePromptDraftStorage( rawValue: string | null, ): PromptDraftState { diff --git a/apps/app/src/views/thread-detail/ThreadDetailFollowUpComposer.tsx b/apps/app/src/views/thread-detail/ThreadDetailFollowUpComposer.tsx new file mode 100644 index 0000000000..1d76c7a4b3 --- /dev/null +++ b/apps/app/src/views/thread-detail/ThreadDetailFollowUpComposer.tsx @@ -0,0 +1,145 @@ +import { memo, useMemo } from "react"; +import { + usePublishPluginComposerHost, + type PluginComposerHost, +} from "@/components/plugin/plugin-composer-host"; +import { + FollowUpPromptBox, + type FollowUpComposerProps, + type FollowUpPromptBoxProps, +} from "@/components/promptbox/FollowUpPromptBox"; +import type { AttachmentsConfig } from "@/components/promptbox/PromptBoxInternal"; +import { + usePromptDraftStorage, + type PromptDraftScope, +} from "@/hooks/usePromptDraftStorage"; +import type { PromptDraftState } from "@/lib/prompt-draft"; + +/** + * The reactive half of the thread's bottom composer. + * + * `ThreadDetailPromptArea` (~70 hooks) must not re-render per keystroke, so it + * holds only an imperative `PromptDraftAccessor` and hands the pieces that + * depend on the live draft — the controlled composer value, the attachment + * list, and the plugin composer host's `draft` — to this component. It owns the + * `usePromptDraftStorage` subscription, so a keystroke re-renders it (and the + * memoized `FollowUpPromptBox` beneath) and nothing above. + * + * A pending permission/question is passed straight through as + * `FollowUpPromptBox`'s `pendingInteraction` (with the reduced `stack`), so + * the same `FollowUpPromptBox` instance — and its TipTap editor, draft and + * pickers — stays mounted across every approval instead of being swapped for + * a different component. + */ + +/** Everything a `PluginComposerHost` needs except the reactive `draft`. */ +export type PluginComposerHostBinding = Omit; + +interface UseThreadDetailComposerDraftArgs { + draftScope: PromptDraftScope; + hostBinding: PluginComposerHostBinding; + /** + * Host of an open inline queued-message editor. While one is open it is the + * pane's published host (plugin composer hooks act on the edit, not the + * bottom draft), exactly as before the subscription moved down here. + */ + inlineEditorHost: PluginComposerHost | null; +} + +function useThreadDetailComposerDraft({ + draftScope, + hostBinding, + inlineEditorHost, +}: UseThreadDetailComposerDraftArgs): { + draft: PromptDraftState; + host: PluginComposerHost; +} { + const promptDraft = usePromptDraftStorage(draftScope); + const draft = useMemo( + () => ({ + text: promptDraft.text, + mentions: promptDraft.mentions, + attachments: promptDraft.attachments, + }), + [promptDraft.attachments, promptDraft.mentions, promptDraft.text], + ); + const host = useMemo( + () => ({ ...hostBinding, draft }), + [draft, hostBinding], + ); + usePublishPluginComposerHost(inlineEditorHost ?? host); + return { draft, host }; +} + +/** The bottom `FollowUpComposerProps` minus the fields derived from the live draft. */ +export type ThreadDetailBottomComposerBinding = Omit< + FollowUpComposerProps, + "history" | "message" | "mentionRanges" | "onChangeMessage" +> & { + historyEntries: readonly PromptDraftState[]; + historyResetKey: string; +}; + +export type ThreadDetailFollowUpComposerProps = Omit< + FollowUpPromptBoxProps, + "attachments" | "composer" | "pluginComposerHost" | "pluginComposerScope" +> & + UseThreadDetailComposerDraftArgs & { + /** Null hides the composer (archived thread, environment gone) and renders the stack only. */ + composer: ThreadDetailBottomComposerBinding | null; + attachments: Omit; + /** Writes the composer text (`PromptDraftAccessor.setTextAndMentions`). */ + onChangeMessage: FollowUpComposerProps["onChangeMessage"]; + /** Replaces the whole draft from the history picker (`PromptDraftAccessor.setDraft`). */ + onSelectHistoryEntry: (draft: PromptDraftState) => void; + }; + +export const ThreadDetailFollowUpComposer = memo( + function ThreadDetailFollowUpComposer({ + draftScope, + hostBinding, + inlineEditorHost, + composer, + attachments, + onChangeMessage, + onSelectHistoryEntry, + ...promptBoxProps + }: ThreadDetailFollowUpComposerProps) { + const { draft, host } = useThreadDetailComposerDraft({ + draftScope, + hostBinding, + inlineEditorHost, + }); + const attachmentsConfig = useMemo( + () => ({ ...attachments, items: draft.attachments }), + [attachments, draft.attachments], + ); + const composerConfig = useMemo(() => { + if (!composer) { + return null; + } + const { historyEntries, historyResetKey, ...rest } = composer; + return { + ...rest, + history: { + currentDraft: draft, + entries: historyEntries, + onSelectEntry: onSelectHistoryEntry, + resetKey: historyResetKey, + }, + message: draft.text, + mentionRanges: draft.mentions, + onChangeMessage, + }; + }, [composer, draft, onChangeMessage, onSelectHistoryEntry]); + return ( + + ); + }, +); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx new file mode 100644 index 0000000000..b5d23e5ec5 --- /dev/null +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -0,0 +1,528 @@ +// @vitest-environment jsdom + +import type { PendingInteraction, ThreadWithRuntime } from "@bb/domain"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + PluginComposerHostScopeProvider, + usePluginComposerHost, +} from "@/components/plugin/plugin-composer-host"; +import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage"; +import { ThreadDetailPromptArea } from "./ThreadDetailPromptArea"; + +/** + * Keystroke isolation for the thread prompt area (mobile-perf D1). + * + * The prompt area body runs ~70 hooks. It must not re-render per keystroke: + * only the composer wrapper (which owns the draft subscription) and the plugin + * host publication may track the live draft. These tests use the real draft + * store so keystrokes flow the way they do in the app. + */ + +const mocks = vi.hoisted(() => ({ + contextBannerRenders: vi.fn(), + queuedMessagesListRenders: vi.fn(), + sendMessageMutateAsync: vi.fn(), + todoCardRenders: vi.fn(), + useThreadCreationOptions: vi.fn(), +})); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => vi.fn() }; +}); + +vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({ + FollowUpPromptBox: ({ + composer, + pendingInteraction = null, + stack, + }: { + composer: { + message: string; + onChangeMessage: (message: string, mentions: []) => void; + onSubmit: () => void; + } | null; + pendingInteraction?: ReactNode; + stack: ReactNode; + }) => ( +
+
+ {stack} + {pendingInteraction} +
+ {composer ? ( + // Like the real FollowUpPromptBox: hidden, not unmounted, while a + // pending interaction takes the composer's place. + + ) : null} +
+ ), +})); + +vi.mock("@/components/promptbox/ThreadEnvironmentSummary", () => ({ + ThreadEnvironmentSummary: () =>
, +})); + +vi.mock("@/components/promptbox/banner/QueuedMessagesList", () => ({ + QueuedMessagesList: () => { + mocks.queuedMessagesListRenders(); + return
; + }, +})); + +vi.mock("@/components/promptbox/banner/ThreadBackgroundCommandsCard", () => ({ + ThreadBackgroundCommandsCard: () => null, +})); + +vi.mock("@/components/promptbox/banner/ThreadGoalCard", () => ({ + ThreadGoalCard: () => null, +})); + +vi.mock("@/components/promptbox/banner/ThreadPromptContextBanner", () => ({ + ThreadPromptContextBanner: () => { + mocks.contextBannerRenders(); + return
; + }, +})); + +vi.mock("@/components/promptbox/banner/ThreadPromptModeCard", () => ({ + ThreadPromptModeCard: () => null, +})); + +vi.mock("@/components/promptbox/banner/ThreadTodoCard", () => ({ + ThreadTodoCard: ({ + isExpanded, + onToggle, + }: { + isExpanded: boolean; + onToggle: () => void; + }) => { + mocks.todoCardRenders(); + return ( + + ); + }, +})); + +vi.mock("@/components/promptbox/banner/ThreadWorkflowCard", () => ({ + ThreadWorkflowCard: () => null, +})); + +vi.mock( + "@/components/thread/pending-interactions/ThreadPendingInteractionBanner", + () => ({ + ThreadPendingInteractionBanner: () => ( +
+ ), + }), +); + +vi.mock("@/components/plugin/PluginPendingInteractionComposer", () => ({ + PluginPendingInteractionComposer: () => null, +})); + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: { error: vi.fn() }, +})); + +vi.mock("@/hooks/useCommandSuggestions", () => ({ + useCommandSuggestions: () => ({ + hasMore: false, + isError: false, + isLoading: false, + isLoadingMore: false, + loadMore: vi.fn(), + suggestions: [], + trigger: null, + }), +})); + +vi.mock("@/hooks/useEscapeToHide", () => ({ + useEscapeToHide: () => undefined, +})); + +vi.mock("@/hooks/usePromptMentions", () => ({ + usePromptMentions: () => ({ + isError: false, + isLoading: false, + setQuery: vi.fn(), + suggestions: [], + }), +})); + +vi.mock("@/hooks/useThreadCreationOptions", () => ({ + useThreadCreationOptions: (options: unknown) => { + // One call per ThreadDetailPromptArea render: the render counter. + mocks.useThreadCreationOptions(options); + return { + activeModel: null, + executionInputSources: {}, + hasMultipleProviders: false, + isLoadingModels: false, + modelLoadError: null, + modelLoadFailed: false, + modelOptions: [], + moreModelOptions: [], + permissionMode: "auto", + permissionModeOptions: [], + providerOptions: [], + reasoningLevel: "medium", + reasoningOptions: [], + selectedModel: "gpt-5", + selectedProviderComposerActions: [], + selectedProviderDisplayName: "Codex", + selectedProviderId: "codex", + serviceTier: undefined, + serviceTierSupportByProvider: {}, + setPermissionMode: vi.fn(), + setReasoningLevel: vi.fn(), + setSelectedModel: vi.fn(), + setServiceTier: vi.fn(), + supportsPermissionModeSelection: true, + supportsServiceTier: false, + }; + }, +})); + +vi.mock("@/hooks/mutations/project-mutations", () => ({ + useUploadPromptAttachment: () => ({ + isPending: false, + mutateAsync: vi.fn(), + }), +})); + +vi.mock("@/hooks/mutations/thread-runtime-mutations", () => { + const idleMutation = () => ({ + isPending: false, + mutate: vi.fn(), + mutateAsync: vi.fn(), + variables: null, + }); + return { + useCancelThreadPlan: idleMutation, + useClearThreadGoal: idleMutation, + useCreateThreadQueuedMessage: idleMutation, + useDeleteThreadQueuedMessage: idleMutation, + useReorderThreadQueuedMessage: idleMutation, + useSetThreadQueuedMessageGroupBoundary: idleMutation, + useSendThreadQueuedMessage: idleMutation, + useStopThread: idleMutation, + useUpdateThreadQueuedMessage: idleMutation, + }; +}); + +vi.mock("@/hooks/mutations/thread-state-mutations", () => ({ + useUnarchiveThread: () => ({ + isPending: false, + mutate: vi.fn(), + variables: null, + }), +})); + +vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ + useProjectDisplayName: () => null, +})); + +vi.mock("@/hooks/queries/thread-default-execution-options-query", () => ({ + useThreadDefaultExecutionOptions: () => ({ + data: { + model: "gpt-5", + permissionMode: "auto", + reasoningLevel: "medium", + serviceTier: "default", + source: "client/turn/requested", + }, + isError: false, + }), +})); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + getLatestPendingInteraction: (interactions: readonly PendingInteraction[]) => + interactions.at(-1) ?? null, + useThreadPromptHistory: () => ({ data: [] }), + useThreadQueuedMessages: () => ({ data: [] }), +})); + +const PROJECT_ID = "proj_keystrokes"; + +function makeThread(id: string): ThreadWithRuntime { + return { + archivedAt: null, + environmentId: null, + id, + projectId: PROJECT_ID, + providerId: "codex", + runtime: { displayStatus: "idle" }, + status: "idle", + } as ThreadWithRuntime; +} + +function makePendingInteraction(threadId: string): PendingInteraction { + return { + id: `interaction-${threadId}`, + threadId, + turnId: "turn-1", + providerId: "codex", + providerThreadId: "provider-thread-1", + providerRequestId: "provider-request-1", + origin: { + kind: "provider", + providerId: "codex", + providerThreadId: "provider-thread-1", + providerRequestId: "provider-request-1", + }, + payload: { + kind: "user_question", + questions: [ + { + id: "question-1", + prompt: "Continue?", + multiSelect: false, + allowFreeText: true, + }, + ], + }, + resolution: null, + status: "pending", + statusReason: null, + createdAt: 1, + resolvedAt: null, + }; +} + +function PublishedHostDraft() { + const host = usePluginComposerHost(); + return
{host?.draft.text ?? ""}
; +} + +interface RenderPromptAreaArgs { + thread: ThreadWithRuntime; + pendingInteractions?: readonly PendingInteraction[]; +} + +function buildPromptArea({ + thread, + pendingInteractions = [], +}: RenderPromptAreaArgs) { + return ( + + + null} + sendMessage={{ + isPending: false, + mutateAsync: mocks.sendMessageMutateAsync, + }} + steerActiveThreadOnEnter={false} + thread={thread} + workspaceChangedFilesSection={null} + workspaceStatusPending={false} + /> + + ); +} + +function renderPromptArea(args: RenderPromptAreaArgs) { + return render(buildPromptArea(args)); +} + +function typeIntoComposer(text: string) { + const input = screen.getByRole("textbox", { + name: "Composer message", + }) as HTMLInputElement; + for (let index = 1; index <= text.length; index += 1) { + fireEvent.change(input, { target: { value: text.slice(0, index) } }); + } + return input; +} + +let threadCounter = 0; +let threadId = ""; + +beforeEach(() => { + threadCounter += 1; + threadId = `thr_keystrokes_${threadCounter}`; + mocks.sendMessageMutateAsync.mockResolvedValue(undefined); +}); + +afterEach(() => { + cleanup(); + getPromptDraftAccessor({ + kind: "thread", + projectId: PROJECT_ID, + threadId, + }).clear(); + vi.clearAllMocks(); +}); + +describe("ThreadDetailPromptArea keystrokes", () => { + it("re-renders the composer, not the prompt area or its stack, per keystroke", () => { + renderPromptArea({ thread: makeThread(threadId) }); + const input = screen.getByRole("textbox", { + name: "Composer message", + }) as HTMLInputElement; + + // The empty -> non-empty flip is the one legitimate prompt-area render + // (it enables the modifier-submit shortcut and escape-to-hide). + fireEvent.change(input, { target: { value: "a" } }); + const areaRendersAfterFlip = + mocks.useThreadCreationOptions.mock.calls.length; + const bannerRendersAfterFlip = mocks.contextBannerRenders.mock.calls.length; + const todoRendersAfterFlip = mocks.todoCardRenders.mock.calls.length; + const queueRendersAfterFlip = + mocks.queuedMessagesListRenders.mock.calls.length; + + const typed = "abcdefghijklmnopqrstu"; + for (let index = 2; index <= typed.length; index += 1) { + fireEvent.change(input, { target: { value: typed.slice(0, index) } }); + } + + expect(input.value).toBe(typed); + expect(screen.getByTestId("published-host-draft").textContent).toBe(typed); + expect(mocks.useThreadCreationOptions.mock.calls.length).toBe( + areaRendersAfterFlip, + ); + expect(mocks.contextBannerRenders.mock.calls.length).toBe( + bannerRendersAfterFlip, + ); + expect(mocks.todoCardRenders.mock.calls.length).toBe(todoRendersAfterFlip); + expect(mocks.queuedMessagesListRenders.mock.calls.length).toBe( + queueRendersAfterFlip, + ); + }); + + it("submits the draft as typed, read at event time", async () => { + renderPromptArea({ thread: makeThread(threadId) }); + typeIntoComposer("Ship it"); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + }); + + expect(mocks.sendMessageMutateAsync).toHaveBeenCalledTimes(1); + expect(mocks.sendMessageMutateAsync.mock.calls[0]?.[0]).toMatchObject({ + input: [{ type: "text", text: "Ship it", mentions: [] }], + }); + expect( + getPromptDraftAccessor({ + kind: "thread", + projectId: PROJECT_ID, + threadId, + }).getCurrent().text, + ).toBe(""); + }); + + it("keeps stack cards expanded across a pending interaction hiding the composer", () => { + const thread = makeThread(threadId); + const { rerender } = renderPromptArea({ thread }); + fireEvent.click(screen.getByTestId("todo-card")); + expect(screen.getByTestId("todo-card").textContent).toBe("expanded"); + + // While a pending interaction is shown the full stack is swapped for the + // reduced pending-interaction stack, so the stack subtree unmounts and + // remounts around it. + rerender( + buildPromptArea({ + thread, + pendingInteractions: [makePendingInteraction(threadId)], + }), + ); + expect(screen.queryByTestId("todo-card")).toBe(null); + expect(screen.getByTestId("pending-interaction")).toBeTruthy(); + + rerender(buildPromptArea({ thread })); + expect(screen.getByTestId("todo-card").textContent).toBe("expanded"); + }); + + it("keeps publishing the live bottom draft to plugin hooks while a pending interaction hides the composer", () => { + const thread = makeThread(threadId); + const accessor = getPromptDraftAccessor({ + kind: "thread", + projectId: PROJECT_ID, + threadId, + }); + renderPromptArea({ + thread, + pendingInteractions: [makePendingInteraction(threadId)], + }); + expect(screen.getByTestId("pending-interaction")).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: "Composer message" })).toBe( + null, + ); + expect(screen.getByTestId("published-host-draft").textContent).toBe(""); + const areaRendersBefore = mocks.useThreadCreationOptions.mock.calls.length; + + act(() => { + accessor.setDraft({ + text: "typed elsewhere", + mentions: [], + attachments: [], + }); + }); + + expect(screen.getByTestId("published-host-draft").textContent).toBe( + "typed elsewhere", + ); + // Only the empty -> non-empty flip reaches the prompt area. + expect(mocks.useThreadCreationOptions.mock.calls.length).toBe( + areaRendersBefore + 1, + ); + + act(() => { + accessor.setDraft({ + text: "typed elsewhere again", + mentions: [], + attachments: [], + }); + }); + expect(screen.getByTestId("published-host-draft").textContent).toBe( + "typed elsewhere again", + ); + expect(mocks.useThreadCreationOptions.mock.calls.length).toBe( + areaRendersBefore + 1, + ); + }); +}); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index b03dd1cdd3..0d2855c40b 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -429,6 +429,10 @@ vi.mock("@/hooks/useEscapeToHide", () => ({ })); vi.mock("@/hooks/usePromptDraftStorage", () => ({ + usePromptDraftAccessor: () => mocks.promptDraft, + usePromptDraftHasSubmittableInput: () => + mocks.promptDraft.text.trim().length > 0 || + mocks.promptDraft.attachments.length > 0, usePromptDraftStorage: () => mocks.promptDraft, })); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 994eefbe02..f5e6fedef5 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -10,6 +10,7 @@ import { createPortal } from "react-dom"; import { NavLink, useNavigate } from "react-router-dom"; import type { IconName } from "@bb/shared-ui/icon"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; +import type { AttachmentsConfig } from "@/components/promptbox/PromptBoxInternal"; import { getFollowUpPromptPlaceholder, getCompactFollowUpPromptPlaceholder, @@ -35,33 +36,20 @@ import type { import type { ChildThreadPendingAttention } from "@/hooks/queries/child-thread-pending-interactions"; import { ThreadPendingInteractionBanner } from "@/components/thread/pending-interactions/ThreadPendingInteractionBanner"; import { PluginPendingInteractionComposer } from "@/components/plugin/PluginPendingInteractionComposer"; -import { - type PluginComposerHost, - usePublishPluginComposerHost, -} from "@/components/plugin/plugin-composer-host"; -import { - ThreadPromptContextBanner, - type ContextBannerMergeBaseConfig, - type ThreadPromptContextBannerExpandedSection, - type ThreadPromptParentThreadSection, - type ThreadPromptChildThreadsSection, - type ThreadPromptPullRequestSection, +import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host"; +import type { + ContextBannerMergeBaseConfig, + ThreadPromptParentThreadSection, + ThreadPromptChildThreadsSection, } from "@/components/promptbox/banner/ThreadPromptContextBanner"; import { ThreadGoalCard } from "@/components/promptbox/banner/ThreadGoalCard"; -import { ThreadTodoCard } from "@/components/promptbox/banner/ThreadTodoCard"; import { ThreadPromptModeCard } from "@/components/promptbox/banner/ThreadPromptModeCard"; -import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflowCard"; -import { ThreadBackgroundCommandsCard } from "@/components/promptbox/banner/ThreadBackgroundCommandsCard"; -import { ThreadModelFallbackCard } from "@/components/promptbox/banner/ThreadModelFallbackCard"; import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame"; import type { WorkspaceChangedFileSelection, WorkspaceChangedFilesSection, } from "@/components/workspace/workspace-change-summary"; -import { - QueuedMessagesList, - type QueuedMessageInlineEditor, -} from "@/components/promptbox/banner/QueuedMessagesList"; +import type { QueuedMessageInlineEditor } from "@/components/promptbox/banner/QueuedMessagesList"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { useComposerTextEffects } from "@/lib/composer-text-effects"; @@ -70,7 +58,12 @@ import { useEscapeToHide } from "@/hooks/useEscapeToHide"; import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; import { useProjectDisplayName } from "@/hooks/queries/sidebar-navigation-query"; import { - useActiveComposerDraft, + usePromptDraftAccessor, + usePromptDraftHasSubmittableInput, + type PromptDraftScope, +} from "@/hooks/usePromptDraftStorage"; +import { + useActiveComposerDraftWriters, useComposerAttachmentUploads, useDraftAttachmentUploads, useComposerTypeahead, @@ -84,7 +77,6 @@ import { useClearThreadGoal, useStopThread, } from "@/hooks/mutations/thread-runtime-mutations"; -import { useUnarchiveThread } from "@/hooks/mutations/thread-state-mutations"; import { getLatestPendingInteraction, useThreadQueuedMessages, @@ -110,6 +102,16 @@ import { type FollowUpSubmitMode, } from "@/components/promptbox/FollowUpPromptBox"; import type { SendMessageMutationLike } from "./threadDetailMutationTypes"; +import { + ThreadDetailFollowUpComposer, + type PluginComposerHostBinding, + type ThreadDetailBottomComposerBinding, +} from "./ThreadDetailFollowUpComposer"; +import { + ThreadDetailPromptStack, + useThreadDetailPromptStackExpansion, + type ThreadDetailPromptStackQueue, +} from "./ThreadDetailPromptStack"; import { buildAutoFollowUpRequest, buildCreateQueuedFollowUpRequest, @@ -121,7 +123,7 @@ import { type FollowUpExecutionSelection, } from "./threadDetailPromptSubmission"; -const ignorePromptBannerFileClick = () => {}; +const EMPTY_PROMPT_INPUT: PromptInput[] = []; export interface ThreadDetailSentMessageEdit { draft: PromptDraftState; @@ -500,29 +502,34 @@ export function ThreadDetailPromptArea({ const stopThread = useStopThread(); const cancelThreadPlan = useCancelThreadPlan(); const clearThreadGoal = useClearThreadGoal(); - const unarchiveThread = useUnarchiveThread(); // The personal project isn't a meaningful label in the footer, so skip it. const projectName = useProjectDisplayName( thread.projectId === PERSONAL_PROJECT_ID ? undefined : thread.projectId, ); + // Deliberately not a draft subscription: the live draft is read at event + // time (`promptDraft.getCurrent()`) or rendered by ThreadDetailFollowUpComposer, + // so a keystroke never re-renders this component (see that file's header). + const draftScope = useMemo( + () => ({ kind: "thread", projectId, threadId: thread.id }), + [projectId, thread.id], + ); + const promptDraft = usePromptDraftAccessor(draftScope); + const hasPromptDraftInput = usePromptDraftHasSubmittableInput(draftScope); + const inlineEditingQueuedMessageInput = useMemo( + () => + inlineEditingQueuedMessage + ? promptDraftToInput(inlineEditingQueuedMessage.draft) + : EMPTY_PROMPT_INPUT, + [inlineEditingQueuedMessage], + ); const { - promptDraft, - currentPromptDraft, - currentPromptDraftInput, - activeComposerDraft, - activeComposerDraftInput, setActiveComposerDraft, handleChangeMessage: handleComposerMessageChange, removeActiveComposerAttachment, - } = useActiveComposerDraft({ - draftScope: { - kind: "thread", - projectId, - threadId: thread.id, - }, - inlineEditingQueuedMessage, + } = useActiveComposerDraftWriters({ inlineEditingQueuedMessageRef, commitInlineQueuedMessage, + storedDraft: promptDraft, }); const updateSentMessageEditDraft = sentMessageEdit?.updateDraft; const addSentMessageEditAttachment = useCallback( @@ -586,60 +593,11 @@ export function ThreadDetailPromptArea({ ? `sent-message:${thread.id}:${sentMessageEdit.operationId}` : null, ); - const [expandedBannerSection, setExpandedBannerSection] = - useState(null); - const pullRequestSection = - useMemo(() => { - if (!pullRequest) { - return null; - } - const actions = - onPullRequestReady || - onPullRequestMerge || - onPullRequestDraft || - isEnvironmentActionPending - ? { - isPending: isEnvironmentActionPending, - ...(onPullRequestReady - ? { onMarkReady: onPullRequestReady } - : {}), - ...(onPullRequestMerge ? { onMerge: onPullRequestMerge } : {}), - ...(onPullRequestDraft - ? { onConvertToDraft: onPullRequestDraft } - : {}), - ...(onPullRequestMerge - ? { selectedMergeMethod: pullRequestMergeMethod } - : {}), - } - : undefined; - return actions ? { pullRequest, actions } : { pullRequest }; - }, [ - isEnvironmentActionPending, - onPullRequestDraft, - onPullRequestMerge, - onPullRequestReady, - pullRequest, - pullRequestMergeMethod, - ]); + // Goal and prompt-mode cards render in both the stack and the + // pending-interaction branch, so their expansion lives here, not in the stack. const [isGoalExpanded, setIsGoalExpanded] = useState(false); - const [isTodoExpanded, setIsTodoExpanded] = useState(false); const [isPromptModeExpanded, setIsPromptModeExpanded] = useState(false); - // Expansion is tracked per workflow id so concurrent workflows expand and - // collapse independently. - const [expandedWorkflowIds, setExpandedWorkflowIds] = useState< - ReadonlySet - >(() => new Set()); - const toggleWorkflowExpanded = useCallback((workflowId: string) => { - setExpandedWorkflowIds((current) => { - const next = new Set(current); - if (!next.delete(workflowId)) { - next.add(workflowId); - } - return next; - }); - }, []); - const [isBackgroundCommandsExpanded, setIsBackgroundCommandsExpanded] = - useState(false); + const promptStackExpansion = useThreadDetailPromptStackExpansion(); const [isFollowUpShortcutSending, setIsFollowUpShortcutSending] = useState(false); const promptHistoryDrafts = useMemo( @@ -746,7 +704,7 @@ export function ThreadDetailPromptArea({ onSaveSuccess: () => setInlineAttachmentError(null), inlineEditingQueuedMessage, dismissInlineQueuedMessageEditor, - activeComposerDraftInput, + activeComposerDraftInput: inlineEditingQueuedMessageInput, }); const isQueueMutationPending = createQueuedMessage.isPending || @@ -788,9 +746,7 @@ export function ThreadDetailPromptArea({ const compactPromptPlaceholder = isStopRequested ? "Stopping thread..." : getCompactFollowUpPromptPlaceholder(runtimeDisplayStatus); - const normalPluginComposerHostBinding = useMemo< - Omit - >( + const normalPluginComposerHostBinding = useMemo( () => ({ scope: { kind: "thread", threadId: thread.id }, textEffectKey: promptDraft.storageKey, @@ -806,14 +762,6 @@ export function ThreadDetailPromptArea({ thread.id, ], ); - const normalPluginComposerHost = useMemo( - () => ({ - ...normalPluginComposerHostBinding, - draft: currentPromptDraft, - }), - [currentPromptDraft, normalPluginComposerHostBinding], - ); - const hasPromptDraftInput = currentPromptDraftInput.length > 0; const isPromptEmpty = useCallback( () => !hasPromptDraftInput, [hasPromptDraftInput], @@ -857,8 +805,8 @@ export function ThreadDetailPromptArea({ ]); const handleSend = useCallback(async () => { - const submittedDraft = currentPromptDraft; - const submittedInput = currentPromptDraftInput; + const submittedDraft = promptDraft.getCurrent(); + const submittedInput = promptDraftToInput(submittedDraft); const isQueuingMessage = shouldQueueFollowUpMessage(runtimeDisplayStatus); if ( submittedInput.length === 0 || @@ -906,8 +854,6 @@ export function ThreadDetailPromptArea({ } }, [ createQueuedMessage, - currentPromptDraft, - currentPromptDraftInput, followUpExecutionSelection, isDefaultExecutionOptionsLoading, promptDraft, @@ -921,8 +867,8 @@ export function ThreadDetailPromptArea({ return; } - const submittedDraft = currentPromptDraft; - const submittedInput = currentPromptDraftInput; + const submittedDraft = promptDraft.getCurrent(); + const submittedInput = promptDraftToInput(submittedDraft); const shortcutRequest = buildFollowUpShortcutRequest({ input: submittedInput, queuedMessages: queuedMessagesRef.current, @@ -971,8 +917,6 @@ export function ThreadDetailPromptArea({ ); }, [ canSubmitModifierShortcut, - currentPromptDraft, - currentPromptDraftInput, promptDraft, queuedMessagesRef, sendMessage, @@ -993,26 +937,6 @@ export function ThreadDetailPromptArea({ const bottomFocusEndKey = `${composerFocusRequestNonce}:${bottomPluginFocusNonce}`; - const handlePromptBannerFileClick = useCallback( - (selection: WorkspaceChangedFileSelection) => { - onChangedFileClick(selection); - }, - [onChangedFileClick], - ); - - const handleToggleBannerSection = useCallback( - (section: ThreadPromptContextBannerExpandedSection | null) => { - setExpandedBannerSection((previous) => - previous === section ? null : section, - ); - }, - [], - ); - const isUnarchiveCurrentThreadPending = - unarchiveThread.isPending && unarchiveThread.variables?.id === thread.id; - const handleUnarchiveCurrentThread = useCallback(() => { - unarchiveThread.mutate({ id: thread.id }); - }, [thread.id, unarchiveThread]); const sourceThreadDisplayTitle = getThreadDisplayTitle({ id: thread.id, title: thread.title, @@ -1035,9 +959,8 @@ export function ThreadDetailPromptArea({ thread.projectId, ]); - const bottomAttachmentsConfig = useMemo( + const bottomAttachmentsConfig = useMemo>( () => ({ - items: currentPromptDraft.attachments, projectId, isAttaching: isAttachingBottomFiles, error: bottomAttachmentError, @@ -1046,7 +969,6 @@ export function ThreadDetailPromptArea({ }), [ bottomAttachmentError, - currentPromptDraft.attachments, handleAttachBottomFiles, isAttachingBottomFiles, projectId, @@ -1063,18 +985,11 @@ export function ThreadDetailPromptArea({ void handleSaveInlineQueuedMessage(); }, [handleSaveInlineQueuedMessage]); - const bottomComposerConfig = useMemo( + const bottomComposerConfig = useMemo( () => ({ - history: { - currentDraft: currentPromptDraft, - entries: promptHistoryDrafts, - onSelectEntry: promptDraft.setDraft, - resetKey: thread.id, - }, + historyEntries: promptHistoryDrafts, + historyResetKey: thread.id, isFollowUpSubmitting, - message: currentPromptDraft.text, - mentionRanges: currentPromptDraft.mentions, - onChangeMessage: promptDraft.setTextAndMentions, onModifierSubmit: handleBottomComposerModifierSubmit, onSubmit: handleBottomComposerSubmit, compactPromptPlaceholder, @@ -1087,14 +1002,11 @@ export function ThreadDetailPromptArea({ [ canSubmitModifierShortcut, compactPromptPlaceholder, - currentPromptDraft, handleBottomComposerModifierSubmit, handleBottomComposerSubmit, isFollowUpSubmitting, promptHistoryDrafts, promptPlaceholder, - promptDraft.setDraft, - promptDraft.setTextAndMentions, runtimeDisplayStatus, steerActiveThreadOnEnter, submitMode, @@ -1328,7 +1240,7 @@ export function ThreadDetailPromptArea({ queuedMessageId, }, textEffectKey: `queued-message:${thread.id}:${queuedMessageId}:${editSessionId}`, - draft: activeComposerDraft, + draft: initialDraft, getCurrent: () => readInlineQueuedMessageDraft( inlineEditingQueuedMessageRef, @@ -1350,7 +1262,7 @@ export function ThreadDetailPromptArea({ onDismiss: dismissInlineQueuedMessageEditor, content: buildInlineDraftComposer({ attachments: { - items: activeComposerDraft.attachments, + items: initialDraft.attachments, projectId, isAttaching: isAttachingInlineFiles, error: inlineAttachmentError, @@ -1358,10 +1270,11 @@ export function ThreadDetailPromptArea({ onRemove: removeActiveComposerAttachment, }, canModifierSubmit: - activeComposerDraftInput.length > 0 && !isUpdateQueuedMessagePending, + inlineEditingQueuedMessageInput.length > 0 && + !isUpdateQueuedMessagePending, compactPromptPlaceholder, composerId: `${THREAD_DETAIL_COMPOSER_TEXTAREA_ID}-queued-${queuedMessageId}`, - draft: activeComposerDraft, + draft: initialDraft, editFocusNonce, execution: inlineExecutionConfig, focusSessionKey: editSessionId, @@ -1383,8 +1296,6 @@ export function ThreadDetailPromptArea({ }; return { inlineEditor, pluginComposerHost }; }, [ - activeComposerDraft, - activeComposerDraftInput.length, commitInlineQueuedMessage, compactPromptPlaceholder, dismissInlineQueuedMessageEditor, @@ -1395,6 +1306,7 @@ export function ThreadDetailPromptArea({ handleInlineComposerSubmit, inlineAttachmentError, inlineEditingQueuedMessage, + inlineEditingQueuedMessageInput.length, inlineEditingQueuedMessageRef, inlineExecutionConfig, inlinePermissionConfig, @@ -1410,9 +1322,7 @@ export function ThreadDetailPromptArea({ thread.id, typeaheadConfig, ]); - usePublishPluginComposerHost( - queuedMessageEditor?.pluginComposerHost ?? normalPluginComposerHost, - ); + const inlineEditorHost = queuedMessageEditor?.pluginComposerHost ?? null; const sentMessageEditorPortal = useMemo(() => { if (!sentMessageEdit?.hostElement) { return null; @@ -1531,142 +1441,75 @@ export function ThreadDetailPromptArea({ ), [childPendingInteractions], ); - const promptStack = useMemo( - () => ( - <> - {childPendingInteractionBanners} - {activeWorkflows.map((workflow) => ( - toggleWorkflowExpanded(workflow.id)} - /> - ))} - setIsBackgroundCommandsExpanded((value) => !value)} - /> - {activePromptModeCard} - {activeGoalCard} - setIsTodoExpanded((value) => !value)} - /> - - {modelFallback ? ( - - ) : null} - {shouldHideComposer ? null : ( - ( + () => + shouldHideComposer + ? null + : { + queuedMessages, + inlineEditor: queuedMessageEditor?.inlineEditor, + sendDisabled: !(submitMode.kind === "ready" || submitMode.kind === "queue") || runtimeDisplayStatus === "provisioning" || runtimeDisplayStatus === "starting" || runtimeDisplayStatus === "waiting-for-host" || isFollowUpSubmitting || - isQueueMutationPending - } - actionDisabled={isQueueMutationPending} - processingMessageId={displayedProcessingQueuedMessage?.id ?? null} - processingAction={displayedProcessingQueuedMessage?.action ?? null} - onSendImmediately={handleSendQueuedImmediately} - onReorder={handleReorderQueuedMessage} - onSetGroupBoundary={handleSetQueuedMessageGroupBoundary} - onEdit={beginEditQueuedMessage} - onDelete={handleDeleteQueuedMessage} - /> - )} - - ), + isQueueMutationPending, + actionDisabled: isQueueMutationPending, + processingMessageId: displayedProcessingQueuedMessage?.id ?? null, + processingAction: displayedProcessingQueuedMessage?.action ?? null, + onSendImmediately: handleSendQueuedImmediately, + onReorder: handleReorderQueuedMessage, + onSetGroupBoundary: handleSetQueuedMessageGroupBoundary, + onEdit: beginEditQueuedMessage, + onDelete: handleDeleteQueuedMessage, + }, [ - canUseGitUi, - childPendingInteractionBanners, - contextBannerMergeBase, - expandedBannerSection, - handleDeleteQueuedMessage, beginEditQueuedMessage, - handlePromptBannerFileClick, + displayedProcessingQueuedMessage, + handleDeleteQueuedMessage, handleReorderQueuedMessage, handleSendQueuedImmediately, handleSetQueuedMessageGroupBoundary, - handleToggleBannerSection, - handleUnarchiveCurrentThread, - environmentGoneStatus, isFollowUpSubmitting, - isUnarchiveCurrentThreadPending, isQueueMutationPending, queuedMessageEditor, - activeGoalCard, - activePromptModeCard, - isTodoExpanded, - activeWorkflows, - expandedWorkflowIds, - toggleWorkflowExpanded, - activeBackgroundCommands, - isBackgroundCommandsExpanded, - modelFallback, - parentThreadSection, - childThreadsSection, - pullRequestSection, - pendingTodos, - displayedProcessingQueuedMessage, queuedMessages, - resolveMentionLink, runtimeDisplayStatus, shouldHideComposer, submitMode.kind, - thread.archivedAt, - thread.id, - workspaceChangedFilesSection, - workspaceStatusPending, ], ); + const promptStack = ( + + ); // A pending permission/question takes the composer's place, but the // composer itself stays mounted (hidden) inside FollowUpPromptBox so the @@ -1707,15 +1550,18 @@ export function ThreadDetailPromptArea({ ); const bottomContent = ( - {}; + +export interface ThreadDetailPromptStackProps { + activeBackgroundCommands: TimelineWorkflowWorkRow[]; + activeWorkflows: TimelineWorkflowWorkRow[]; + /** Goal card element built by the area (shared with the pending-interaction branch). */ + activeGoalCard: ReactNode; + /** Prompt-mode card element built by the area (shared with the pending-interaction branch). */ + activePromptModeCard: ReactNode; + archivedAt: ThreadWithRuntime["archivedAt"]; + canUseGitUi: boolean; + /** Pending permission/question banners from delegated child threads. */ + childPendingInteractionBanners: ReactNode; + childThreadsSection: ThreadPromptChildThreadsSection | null; + contextBannerMergeBase: ContextBannerMergeBaseConfig | null; + environmentGoneStatus: Extract< + EnvironmentStatus, + "destroying" | "destroyed" + > | null; + /** Expand/collapse state, owned by the area (see `useThreadDetailPromptStackExpansion`). */ + expansion: ThreadDetailPromptStackExpansion; + isEnvironmentActionPending: boolean; + modelFallback: ThreadTimelineModelFallback | null; + onChangedFileClick: (selection: WorkspaceChangedFileSelection) => void; + onPullRequestDraft?: () => void; + onPullRequestMerge?: (method: PullRequestMergeMethod) => void; + onPullRequestReady?: () => void; + parentThreadSection: ThreadPromptParentThreadSection | null; + pendingTodos: ThreadTimelinePendingTodos | null; + pullRequest: ThreadPullRequest | null; + pullRequestMergeMethod: PullRequestMergeMethod; + /** Null hides the queue (archived thread, environment gone). */ + queue: ThreadDetailPromptStackQueue | null; + resolveMentionLink: PromptMentionLinkResolver; + threadId: string; + workspaceChangedFilesSection: WorkspaceChangedFilesSection | null; + workspaceStatusPending: boolean; +} + +export type ThreadDetailPromptStackQueue = Omit< + QueuedMessagesListProps, + "resolveMentionLink" +>; + +export interface ThreadDetailPromptStackExpansion { + expandedBannerSection: ThreadPromptContextBannerExpandedSection | null; + onToggleBannerSection: ( + section: ThreadPromptContextBannerExpandedSection | null, + ) => void; + isTodoExpanded: boolean; + onToggleTodo: () => void; + expandedWorkflowIds: ReadonlySet; + onToggleWorkflow: (workflowId: string) => void; + isBackgroundCommandsExpanded: boolean; + onToggleBackgroundCommands: () => void; +} + +/** + * Expand/collapse state for the stack's cards. Lives in the area, not in + * `ThreadDetailPromptStack`, because the stack unmounts whenever the composer + * slot swaps (a pending permission/question, the composer hiding, a plugin + * composer taking over) and an expanded todo list or banner section must + * survive that round trip. The returned object is memoized so the stack only + * re-renders on a toggle. + */ +export function useThreadDetailPromptStackExpansion(): ThreadDetailPromptStackExpansion { + const [expandedBannerSection, setExpandedBannerSection] = + useState(null); + const onToggleBannerSection = useCallback( + (section: ThreadPromptContextBannerExpandedSection | null) => { + setExpandedBannerSection((previous) => + previous === section ? null : section, + ); + }, + [], + ); + const [isTodoExpanded, setIsTodoExpanded] = useState(false); + const onToggleTodo = useCallback(() => { + setIsTodoExpanded((value) => !value); + }, []); + // Expansion is tracked per workflow id so concurrent workflows expand and + // collapse independently. + const [expandedWorkflowIds, setExpandedWorkflowIds] = useState< + ReadonlySet + >(() => new Set()); + const onToggleWorkflow = useCallback((workflowId: string) => { + setExpandedWorkflowIds((current) => { + const next = new Set(current); + if (!next.delete(workflowId)) { + next.add(workflowId); + } + return next; + }); + }, []); + const [isBackgroundCommandsExpanded, setIsBackgroundCommandsExpanded] = + useState(false); + const onToggleBackgroundCommands = useCallback(() => { + setIsBackgroundCommandsExpanded((value) => !value); + }, []); + return useMemo( + () => ({ + expandedBannerSection, + onToggleBannerSection, + isTodoExpanded, + onToggleTodo, + expandedWorkflowIds, + onToggleWorkflow, + isBackgroundCommandsExpanded, + onToggleBackgroundCommands, + }), + [ + expandedBannerSection, + expandedWorkflowIds, + isBackgroundCommandsExpanded, + isTodoExpanded, + onToggleBackgroundCommands, + onToggleBannerSection, + onToggleTodo, + onToggleWorkflow, + ], + ); +} + +/** + * The context cards, banner and queued-message list stacked above the thread + * composer. Memoized so a keystroke (which never reaches here) or an unrelated + * prompt-area render does not rebuild the cards. Expand/collapse state comes in + * through `expansion` (see `useThreadDetailPromptStackExpansion`). + */ +export const ThreadDetailPromptStack = memo(function ThreadDetailPromptStack({ + activeBackgroundCommands, + activeWorkflows, + activeGoalCard, + activePromptModeCard, + archivedAt, + canUseGitUi, + childPendingInteractionBanners, + childThreadsSection, + contextBannerMergeBase, + environmentGoneStatus, + expansion, + isEnvironmentActionPending, + modelFallback, + onChangedFileClick, + onPullRequestDraft, + onPullRequestMerge, + onPullRequestReady, + parentThreadSection, + pendingTodos, + pullRequest, + pullRequestMergeMethod, + queue, + resolveMentionLink, + threadId, + workspaceChangedFilesSection, + workspaceStatusPending, +}: ThreadDetailPromptStackProps) { + const { + expandedBannerSection, + expandedWorkflowIds, + isBackgroundCommandsExpanded, + isTodoExpanded, + onToggleBackgroundCommands, + onToggleBannerSection, + onToggleTodo, + onToggleWorkflow, + } = expansion; + const unarchiveThread = useUnarchiveThread(); + const isUnarchiveCurrentThreadPending = + unarchiveThread.isPending && unarchiveThread.variables?.id === threadId; + const handleUnarchiveCurrentThread = useCallback(() => { + unarchiveThread.mutate({ id: threadId }); + }, [threadId, unarchiveThread]); + const pullRequestSection = + useMemo(() => { + if (!pullRequest) { + return null; + } + const actions = + onPullRequestReady || + onPullRequestMerge || + onPullRequestDraft || + isEnvironmentActionPending + ? { + isPending: isEnvironmentActionPending, + ...(onPullRequestReady + ? { onMarkReady: onPullRequestReady } + : {}), + ...(onPullRequestMerge ? { onMerge: onPullRequestMerge } : {}), + ...(onPullRequestDraft + ? { onConvertToDraft: onPullRequestDraft } + : {}), + ...(onPullRequestMerge + ? { selectedMergeMethod: pullRequestMergeMethod } + : {}), + } + : undefined; + return actions ? { pullRequest, actions } : { pullRequest }; + }, [ + isEnvironmentActionPending, + onPullRequestDraft, + onPullRequestMerge, + onPullRequestReady, + pullRequest, + pullRequestMergeMethod, + ]); + + return ( + <> + {childPendingInteractionBanners} + {activeWorkflows.map((workflow) => ( + onToggleWorkflow(workflow.id)} + /> + ))} + + {activePromptModeCard} + {activeGoalCard} + + + {modelFallback ? ( + + ) : null} + {queue ? ( + + ) : null} + + ); +});