diff --git a/apps/app/src/components/promptbox/useComposerArea.ts b/apps/app/src/components/promptbox/useComposerArea.ts new file mode 100644 index 000000000..a7b8acd90 --- /dev/null +++ b/apps/app/src/components/promptbox/useComposerArea.ts @@ -0,0 +1,504 @@ +import { + useCallback, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import type { PermissionMode, PromptTextMention } from "@bb/domain"; +import type { + CreateExecutionInputSources, + ExistingThreadExecutionInputSources, +} from "@bb/server-contract"; +import { + type AttachmentsConfig, + type PromptBoxAction, + type TypeaheadConfig, +} from "@/components/promptbox/PromptBoxInternal"; +import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; +import { + type ExecutionControlsProps, + type ExecutionPermissionConfig, +} from "@/components/promptbox/ExecutionControls"; +import { buildProviderPromptActionProps } from "@/components/promptbox/mentions/command-trigger"; +import { withLoopPromptAction } from "@/components/promptbox/PromptBoxActionsMenu"; +import { + useThreadCreationOptions, + type UseThreadCreationOptionsResult, +} from "@/hooks/useThreadCreationOptions"; +import type { + ScopedExecutionInputSources, + UseComponentLocalCreationOptions, + UseNewThreadCreationOptions, +} from "@/hooks/thread-creation-options/selection-state"; +import { useCommandSuggestions } from "@/hooks/useCommandSuggestions"; +import { + usePromptMentions, + type UsePromptMentionsOptions, +} from "@/hooks/usePromptMentions"; +import { + usePromptDraftStorage, + type PromptDraftScope, +} from "@/hooks/usePromptDraftStorage"; +import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations"; +import { promptDraftToInput, type PromptDraftState } from "@/lib/prompt-draft"; +import { getMutationErrorMessage } from "@/lib/mutation-errors"; + +const noop = () => {}; + +/** + * How a prompt-box footer wires its execution controls. Read-only footers (the + * side chat, which inherits the parent thread's model) render the SAME pickers + * disabled, so every onChange is a no-op. New-thread compose lets the user + * switch providers; committed-thread follow-ups keep the provider locked. + */ +export interface ComposerAreaExecutionConfig { + /** Render execution controls read-only: every onChange becomes a no-op. */ + readOnly?: boolean; + /** + * Wire the provider picker's onChange to the live setter. Only the new-thread + * composer switches providers; follow-ups and side chats leave it locked. + */ + providerSwitchable?: boolean; +} + +/** + * Permission picker shaping, matching the three existing surfaces exactly: + * - `editable` — new-thread compose (plain value + setter). + * - `editable-gated` — follow-up, where the value/options are suppressed until + * the thread's resolved execution defaults arrive (`resolved`). + * - `read-only` — side chat, pinned to a constant with a no-op onChange. + */ +export type ComposerAreaPermissionConfig = + | { kind: "editable" } + | { kind: "editable-gated"; resolved: boolean } + | { kind: "read-only"; value: PermissionMode }; + +export interface ComposerAreaCommandsConfig { + projectId: string | undefined; + /** + * Provider whose commands are discovered. Pass the committed thread's provider + * for follow-ups/side chats; omit to follow the live provider picker (the + * new-thread composer, where the user can switch providers). + */ + providerId?: string; + /** + * Environment that scopes command discovery. Committed threads pass a static + * id; the new-thread composer derives it from the resolved environment, which + * is itself produced here — so it may instead be a resolver that receives the + * live `environmentSelectionValue`. + */ + environmentId: + | string + | null + | ((environmentSelectionValue: string) => string | null); +} + +export interface ComposerAreaAttachmentsConfig { + projectId: string; + /** + * How an upload failure is surfaced. `collect` (default) attempts every file + * and lists the failed names; `first` stops at the first failure and shows + * that mutation's error message. + */ + errorMode?: "collect" | "first"; +} + +interface UseComposerAreaBaseOptions { + draftScope: PromptDraftScope; + /** Project scope for `@`-mention discovery (`undefined` for projectless). */ + mentionsProjectId: string | undefined; + mentions: UsePromptMentionsOptions; + commands: ComposerAreaCommandsConfig; + resolveMentionLink: PromptMentionLinkResolver; + attachments: ComposerAreaAttachmentsConfig; + execution: ComposerAreaExecutionConfig; + permission: ComposerAreaPermissionConfig; +} + +export interface UseComposerAreaComponentLocalOptions + extends UseComposerAreaBaseOptions { + creationOptions: UseComponentLocalCreationOptions; +} + +export interface UseComposerAreaNewThreadOptions + extends UseComposerAreaBaseOptions { + creationOptions: UseNewThreadCreationOptions; +} + +/** + * Draft-derived composer values shared by every prompt box. The submit handlers + * and submit/placeholder state stay per-site (they differ by surface), so this + * exposes the draft, the change handler, and the parsed input the site needs to + * build its own composer config. + */ +export interface ComposerAreaComposer { + message: string; + mentionRanges: readonly PromptTextMention[]; + onChangeMessage: (value: string, mentionRanges: PromptTextMention[]) => void; + currentPromptDraft: PromptDraftState; + currentPromptDraftInput: ReturnType; + hasPromptDraftInput: boolean; +} + +export interface UseComposerAreaResult { + threadCreationOptions: UseThreadCreationOptionsResult; + promptDraft: ReturnType; + promptActions: readonly PromptBoxAction[]; + providerPromptActionProps: { promptActions: readonly PromptBoxAction[] }; + executionConfig: ExecutionControlsProps; + permissionConfig: ExecutionPermissionConfig; + typeaheadConfig: TypeaheadConfig; + attachmentsConfig: AttachmentsConfig; + composer: ComposerAreaComposer; + attachmentError: string | null; + setAttachmentError: Dispatch>; +} + +/** + * Assembles the prompt-composer wiring shared by every prompt box: thread + * creation options, the draft store, `@`-mention + command typeahead, attachment + * upload, and the provider-owned prompt actions — pre-built into the + * execution/permission/typeahead/attachments configs the box needs. Surfaces + * differ on the box itself, the submit handler, and their chrome (project / + * branch pickers, queued-message stacks); those stay per-site. + */ +export function useComposerArea( + options: UseComposerAreaComponentLocalOptions, +): UseComposerAreaResult; +export function useComposerArea( + options: UseComposerAreaNewThreadOptions, +): UseComposerAreaResult; +export function useComposerArea( + options: UseComposerAreaComponentLocalOptions | UseComposerAreaNewThreadOptions, +): UseComposerAreaResult { + const { + attachments, + commands, + creationOptions, + draftScope, + execution, + mentions, + mentionsProjectId, + permission, + resolveMentionLink, + } = options; + + const threadCreationOptions = useThreadCreationOptions( + creationOptions as UseNewThreadCreationOptions, + ); + const { + selectedProviderId, + setSelectedProviderId, + providerOptions, + hasMultipleProviders, + selectedProviderDisplayName, + selectedProviderComposerActions, + selectedModel, + setSelectedModel, + serviceTier, + setServiceTier, + reasoningLevel, + setReasoningLevel, + permissionMode, + setPermissionMode, + activeModel, + modelOptions, + moreModelOptions, + isLoadingModels, + modelLoadFailed, + modelLoadError, + reasoningOptions, + permissionModeOptions, + supportsPermissionModeSelection, + supportsServiceTier, + serviceTierSupportByProvider, + } = threadCreationOptions; + + const promptDraft = usePromptDraftStorage(draftScope); + const promptMentions = usePromptMentions(mentionsProjectId, mentions); + const uploadPromptAttachment = useUploadPromptAttachment(); + const [commandQuery, setCommandQuery] = useState(null); + const [attachmentError, setAttachmentError] = useState(null); + + const providerPromptActions = useMemo( + () => buildProviderPromptActionProps(selectedProviderComposerActions), + [selectedProviderComposerActions], + ); + const promptActions = useMemo( + () => withLoopPromptAction(providerPromptActions.promptActions), + [providerPromptActions.promptActions], + ); + const providerPromptActionProps = useMemo( + () => ({ promptActions }), + [promptActions], + ); + + const commandEnvironmentId = + typeof commands.environmentId === "function" + ? commands.environmentId(threadCreationOptions.environmentSelectionValue) + : commands.environmentId; + const commandSuggestions = useCommandSuggestions({ + projectId: commands.projectId, + providerId: commands.providerId ?? selectedProviderId, + skillsTrigger: providerPromptActions.skillsTrigger, + environmentId: commandEnvironmentId, + query: commandQuery, + }); + + 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 hasPromptDraftInput = currentPromptDraftInput.length > 0; + + const attachmentProjectId = attachments.projectId; + const attachmentErrorMode = attachments.errorMode ?? "collect"; + const handleAttachFiles = useCallback( + async (files: File[]) => { + if (files.length === 0) { + return; + } + + setAttachmentError(null); + if (attachmentErrorMode === "first") { + for (const file of files) { + try { + const uploaded = await uploadPromptAttachment.mutateAsync({ + projectId: attachmentProjectId, + file, + }); + promptDraft.addAttachment(uploaded); + } catch (error) { + setAttachmentError( + getMutationErrorMessage({ + error, + fallbackMessage: "Attachment upload failed", + }), + ); + break; + } + } + return; + } + + const failedFiles: string[] = []; + for (const file of files) { + try { + const uploaded = await uploadPromptAttachment.mutateAsync({ + projectId: attachmentProjectId, + file, + }); + promptDraft.addAttachment(uploaded); + } catch { + failedFiles.push(file.name); + } + } + if (failedFiles.length > 0) { + setAttachmentError(`Failed to attach: ${failedFiles.join(", ")}`); + } + }, + [ + attachmentErrorMode, + attachmentProjectId, + promptDraft, + uploadPromptAttachment, + ], + ); + + const attachmentsConfig = useMemo( + () => ({ + items: promptDraft.attachments, + projectId: attachmentProjectId, + isAttaching: uploadPromptAttachment.isPending, + error: attachmentError, + onAttachFiles: handleAttachFiles, + onRemove: promptDraft.removeAttachment, + }), + [ + attachmentError, + attachmentProjectId, + handleAttachFiles, + promptDraft.attachments, + promptDraft.removeAttachment, + uploadPromptAttachment.isPending, + ], + ); + + const typeaheadConfig = useMemo( + () => ({ + mention: { + suggestions: promptMentions.suggestions, + isLoading: promptMentions.isLoading, + isError: promptMentions.isError, + onQueryChange: promptMentions.setQuery, + resolveLink: resolveMentionLink, + }, + command: { + trigger: commandSuggestions.trigger, + suggestions: commandSuggestions.suggestions, + isLoading: commandSuggestions.isLoading, + isError: commandSuggestions.isError, + hasMore: commandSuggestions.hasMore, + isLoadingMore: commandSuggestions.isLoadingMore, + loadMore: commandSuggestions.loadMore, + onQueryChange: setCommandQuery, + }, + }), + [ + commandSuggestions.hasMore, + commandSuggestions.isError, + commandSuggestions.isLoading, + commandSuggestions.isLoadingMore, + commandSuggestions.loadMore, + commandSuggestions.suggestions, + commandSuggestions.trigger, + promptMentions.isError, + promptMentions.isLoading, + promptMentions.setQuery, + promptMentions.suggestions, + resolveMentionLink, + ], + ); + + const executionReadOnly = execution.readOnly ?? false; + const providerSwitchable = execution.providerSwitchable ?? false; + const executionConfig = useMemo( + () => ({ + provider: { + options: providerOptions, + selectedId: selectedProviderId, + onChange: + !executionReadOnly && providerSwitchable + ? setSelectedProviderId + : undefined, + hasMultiple: hasMultipleProviders, + displayName: selectedProviderDisplayName, + }, + model: { + active: activeModel, + selected: selectedModel, + options: modelOptions, + moreOptions: moreModelOptions, + isLoading: isLoadingModels, + loadFailed: modelLoadFailed, + loadError: modelLoadError, + onChange: executionReadOnly ? noop : setSelectedModel, + }, + serviceTier: { + value: serviceTier, + onChange: executionReadOnly ? noop : setServiceTier, + supported: supportsServiceTier, + supportByProvider: serviceTierSupportByProvider, + }, + reasoning: { + value: reasoningLevel, + options: reasoningOptions, + onChange: executionReadOnly ? noop : setReasoningLevel, + }, + }), + [ + activeModel, + executionReadOnly, + hasMultipleProviders, + isLoadingModels, + modelLoadError, + modelLoadFailed, + modelOptions, + moreModelOptions, + providerOptions, + providerSwitchable, + reasoningLevel, + reasoningOptions, + selectedModel, + selectedProviderDisplayName, + selectedProviderId, + serviceTier, + serviceTierSupportByProvider, + setReasoningLevel, + setSelectedModel, + setSelectedProviderId, + setServiceTier, + supportsServiceTier, + ], + ); + + const permissionKind = permission.kind; + const permissionPinnedValue = + permission.kind === "read-only" ? permission.value : undefined; + const permissionResolved = + permission.kind === "editable-gated" ? permission.resolved : false; + const permissionConfig = useMemo(() => { + if (permissionKind === "read-only") { + return { + value: permissionPinnedValue, + options: permissionModeOptions, + onChange: noop, + supported: supportsPermissionModeSelection, + }; + } + if (permissionKind === "editable-gated") { + return { + value: permissionResolved ? permissionMode : undefined, + options: permissionResolved ? permissionModeOptions : [], + onChange: setPermissionMode, + supported: permissionResolved && supportsPermissionModeSelection, + }; + } + return { + value: permissionMode, + options: permissionModeOptions, + onChange: setPermissionMode, + supported: supportsPermissionModeSelection, + }; + }, [ + permissionKind, + permissionMode, + permissionModeOptions, + permissionPinnedValue, + permissionResolved, + setPermissionMode, + supportsPermissionModeSelection, + ]); + + const composer = useMemo( + () => ({ + message: promptDraft.text, + mentionRanges: promptDraft.mentions, + onChangeMessage: promptDraft.setTextAndMentions, + currentPromptDraft, + currentPromptDraftInput, + hasPromptDraftInput, + }), + [ + currentPromptDraft, + currentPromptDraftInput, + hasPromptDraftInput, + promptDraft.mentions, + promptDraft.setTextAndMentions, + promptDraft.text, + ], + ); + + return { + threadCreationOptions, + promptDraft, + promptActions, + providerPromptActionProps, + executionConfig, + permissionConfig, + typeaheadConfig, + attachmentsConfig, + composer, + attachmentError, + setAttachmentError, + }; +} diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx index 65a93867d..8278c79a2 100644 --- a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx +++ b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx @@ -23,35 +23,20 @@ import { formatEnvironmentDisplay, type EnvironmentDisplayHostContext, } from "@bb/core-ui"; -import { - type AttachmentsConfig, - type HistoryConfig, - type TypeaheadConfig, -} from "@/components/promptbox/PromptBoxInternal"; +import { type HistoryConfig } from "@/components/promptbox/PromptBoxInternal"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { BottomAnchoredScrollBody } from "@/components/ui/bottom-anchored-scroll-body"; import { FollowUpPromptBox, type FollowUpComposerProps, } from "@/components/promptbox/FollowUpPromptBox"; -import { withLoopPromptAction } from "@/components/promptbox/PromptBoxActionsMenu"; import { QueuedMessagesList, type QueuedMessageProcessingAction, } from "@/components/promptbox/banner/QueuedMessagesList"; -import type { - ExecutionControlsProps, - ExecutionPermissionConfig, -} from "@/components/promptbox/ExecutionControls"; -import { buildProviderPromptActionProps } from "@/components/promptbox/mentions/command-trigger"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; -import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; -import { useCommandSuggestions } from "@/hooks/useCommandSuggestions"; -import { usePromptMentions } from "@/hooks/usePromptMentions"; -import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; -import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations"; +import { useComposerArea } from "@/components/promptbox/useComposerArea"; import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; -import { promptDraftToInput } from "@/lib/prompt-draft"; import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { Icon } from "@/components/ui/icon.js"; import { @@ -341,23 +326,7 @@ export function SideChatTabContent({ markThreadRead, thread: isActive ? childThreadQuery.data : undefined, }); - // Build the SAME execution + permission configs the main thread builds (see - // ThreadDetailPromptArea), seeded from the parent thread's resolved options - // and its environment's provider/model catalog. The side chat renders these - // through the identical pickers, just disabled (read-only) — so the model and - // permission labels match the main thread exactly. Permission is pinned to - // "readonly": a side chat never writes to the workspace. const defaultExecutionOptions = executionOptionsQuery.data; - const threadCreationOptions = useThreadCreationOptions({ - scope: "component-local", - environmentId: sourceThread.environmentId ?? undefined, - resetKey: sourceThread.id, - initialProviderId: sourceThread.providerId, - initialModel: defaultExecutionOptions?.model, - initialServiceTier: defaultExecutionOptions?.serviceTier, - initialReasoningLevel: defaultExecutionOptions?.reasoningLevel, - initialPermissionMode: "readonly", - }); // `tab.threadId` only flips after async create resolves and panel state // propagates. Keep the in-flight create promise here so repeated submit // attempts share one side-chat thread. @@ -368,41 +337,59 @@ export function SideChatTabContent({ const observedChildThreadIdRef = useRef(childThreadId); const isMountedRef = useRef(false); const queuedMessageCountRef = useRef(0); - const promptDraft = usePromptDraftStorage({ - kind: "side-chat", - parentThreadId: sourceThread.id, - tabId: tab.id, - }); const promptContextEnvironmentId = childThreadQuery.data?.environmentId ?? sourceThread.environmentId ?? null; const promptContextThreadId = childThreadId ?? sourceThread.id; - const promptMentions = usePromptMentions(sourceThread.projectId, { - currentThreadId: promptContextThreadId, - environmentId: promptContextEnvironmentId, - }); - const [commandQuery, setCommandQuery] = useState(null); - const providerPromptActions = useMemo( - () => - buildProviderPromptActionProps( - threadCreationOptions.selectedProviderComposerActions ?? [], - ), - [threadCreationOptions.selectedProviderComposerActions], - ); - const promptActions = useMemo( - () => withLoopPromptAction(providerPromptActions.promptActions), - [providerPromptActions.promptActions], - ); - const commandSuggestions = useCommandSuggestions({ - projectId: sourceThread.projectId, - providerId: sourceThread.providerId, - skillsTrigger: providerPromptActions.skillsTrigger, - environmentId: promptContextEnvironmentId, - query: commandQuery, + // Build the SAME execution + permission configs the main thread builds (see + // ThreadDetailPromptArea), seeded from the parent thread's resolved options + // and its environment's provider/model catalog. The side chat renders these + // through the identical pickers, just disabled (read-only, via the + // FollowUpPromptBox `readOnly` flag below) — so the model and permission + // labels match the main thread exactly. Permission is pinned to "readonly": + // a side chat never writes to the workspace. + const composerArea = useComposerArea({ + creationOptions: { + scope: "component-local", + environmentId: sourceThread.environmentId ?? undefined, + resetKey: sourceThread.id, + initialProviderId: sourceThread.providerId, + initialModel: defaultExecutionOptions?.model, + initialServiceTier: defaultExecutionOptions?.serviceTier, + initialReasoningLevel: defaultExecutionOptions?.reasoningLevel, + initialPermissionMode: "readonly", + }, + draftScope: { + kind: "side-chat", + parentThreadId: sourceThread.id, + tabId: tab.id, + }, + mentionsProjectId: sourceThread.projectId, + mentions: { + currentThreadId: promptContextThreadId, + environmentId: promptContextEnvironmentId, + }, + commands: { + projectId: sourceThread.projectId, + providerId: sourceThread.providerId, + environmentId: promptContextEnvironmentId, + }, + resolveMentionLink, + attachments: { projectId: sourceThread.projectId }, + execution: { readOnly: true }, + permission: { kind: "read-only", value: SIDE_CHAT_PERMISSION_MODE }, }); - const uploadPromptAttachment = useUploadPromptAttachment(); + const { + attachmentsConfig, + composer, + executionConfig, + permissionConfig, + promptActions, + promptDraft, + setAttachmentError, + typeaheadConfig, + } = composerArea; const [composerFocusNonce, setComposerFocusNonce] = useState(0); - const [attachmentError, setAttachmentError] = useState(null); const [isSideChatTurnSubmitting, setIsSideChatTurnSubmitting] = useState(false); const [optimisticFirstUserRow, setOptimisticFirstUserRow] = @@ -411,25 +398,8 @@ export function SideChatTabContent({ action: QueuedMessageProcessingAction; id: string; } | null>(null); - const handleChangeMessage = useCallback( - (nextValue: string, nextMentions: PromptTextMention[]) => { - promptDraft.setTextAndMentions(nextValue, nextMentions); - }, - [promptDraft], - ); - 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 hasPromptDraftInput = currentPromptDraftInput.length > 0; + const { currentPromptDraft, currentPromptDraftInput, hasPromptDraftInput } = + composer; // The anchored-message reply reference: present only when the anchor is NOT // the parent's last conversation message (the most recent exchange needs no @@ -706,31 +676,6 @@ export function SideChatTabContent({ : isSideChatProvisioning ? "Provisioning side chat..." : "Reply in the side chat…"; - const handleAttachFiles = useCallback( - async (files: File[]) => { - if (files.length === 0) { - return; - } - - setAttachmentError(null); - const failedFiles: string[] = []; - for (const file of files) { - try { - const uploaded = await uploadPromptAttachment.mutateAsync({ - projectId: sourceThread.projectId, - file, - }); - promptDraft.addAttachment(uploaded); - } catch { - failedFiles.push(file.name); - } - } - if (failedFiles.length > 0) { - setAttachmentError(`Failed to attach: ${failedFiles.join(", ")}`); - } - }, - [promptDraft, sourceThread.projectId, uploadPromptAttachment], - ); const handleSubmit = useCallback(() => { const submittedDraft = currentPromptDraft; const submittedInput = currentPromptDraftInput; @@ -784,6 +729,7 @@ export function SideChatTabContent({ isSideChatTurnSubmitting, promptDraft, sendOrQueueSideChatInput, + setAttachmentError, sideChatRuntimeDisplayStatus, tab.id, ]); @@ -889,6 +835,7 @@ export function SideChatTabContent({ promptDraft, queuedMessages, sendThreadMessage, + setAttachmentError, ]); const handleEditQueuedMessage = useCallback( @@ -1030,7 +977,7 @@ export function SideChatTabContent({ isFollowUpSubmitting: isSideChatTurnSubmitting, message: promptDraft.text, mentionRanges: promptDraft.mentions, - onChangeMessage: handleChangeMessage, + onChangeMessage: composer.onChangeMessage, onModifierSubmit: handleModifierSubmit, onSubmit: handleSubmit, promptPlaceholder: composerPlaceholder, @@ -1040,8 +987,8 @@ export function SideChatTabContent({ }), [ canSubmitModifierShortcut, + composer.onChangeMessage, composerPlaceholder, - handleChangeMessage, handleModifierSubmit, handleSubmit, isSideChatTurnSubmitting, @@ -1053,147 +1000,6 @@ export function SideChatTabContent({ ], ); - const attachmentsConfig = useMemo( - () => ({ - items: promptDraft.attachments, - projectId: sourceThread.projectId, - isAttaching: uploadPromptAttachment.isPending, - error: attachmentError, - onAttachFiles: handleAttachFiles, - onRemove: promptDraft.removeAttachment, - }), - [ - attachmentError, - handleAttachFiles, - promptDraft.attachments, - promptDraft.removeAttachment, - sourceThread.projectId, - uploadPromptAttachment.isPending, - ], - ); - - const typeaheadConfig = useMemo( - () => ({ - mention: { - suggestions: promptMentions.suggestions, - isLoading: promptMentions.isLoading, - isError: promptMentions.isError, - onQueryChange: promptMentions.setQuery, - resolveLink: resolveMentionLink, - }, - command: { - trigger: commandSuggestions.trigger, - suggestions: commandSuggestions.suggestions, - isLoading: commandSuggestions.isLoading, - isError: commandSuggestions.isError, - hasMore: commandSuggestions.hasMore, - isLoadingMore: commandSuggestions.isLoadingMore, - loadMore: commandSuggestions.loadMore, - onQueryChange: setCommandQuery, - }, - }), - [ - commandSuggestions.hasMore, - commandSuggestions.isError, - commandSuggestions.isLoading, - commandSuggestions.isLoadingMore, - commandSuggestions.loadMore, - commandSuggestions.suggestions, - commandSuggestions.trigger, - promptMentions.isError, - promptMentions.isLoading, - promptMentions.setQuery, - promptMentions.suggestions, - resolveMentionLink, - ], - ); - - // Built the same shape as the main thread's executionConfig (see - // ThreadDetailPromptArea), but the side chat is read-only: the footer pickers - // render disabled via the FollowUpPromptBox `readOnly` flag, so the controls - // are display-only and their `onChange` is a no-op. The hook supplies the - // inherited display values (provider / model / reasoning / permission options). - const { - selectedProviderId, - providerOptions, - hasMultipleProviders, - selectedProviderDisplayName, - selectedModel, - serviceTier, - reasoningLevel, - activeModel, - modelOptions, - moreModelOptions, - modelLoadError, - reasoningOptions, - permissionModeOptions, - supportsPermissionModeSelection, - supportsServiceTier, - serviceTierSupportByProvider, - isLoadingModels, - } = threadCreationOptions; - - const executionConfig = useMemo( - () => ({ - provider: { - options: providerOptions, - selectedId: selectedProviderId, - hasMultiple: hasMultipleProviders, - displayName: selectedProviderDisplayName, - }, - model: { - active: activeModel, - selected: selectedModel, - options: modelOptions, - moreOptions: moreModelOptions, - loadError: modelLoadError, - isLoading: isLoadingModels, - loadFailed: modelLoadError !== null, - onChange: noop, - }, - serviceTier: { - value: serviceTier, - onChange: noop, - supported: supportsServiceTier, - supportByProvider: serviceTierSupportByProvider, - }, - reasoning: { - value: reasoningLevel, - options: reasoningOptions, - onChange: noop, - }, - }), - [ - activeModel, - hasMultipleProviders, - isLoadingModels, - modelLoadError, - modelOptions, - moreModelOptions, - providerOptions, - reasoningLevel, - reasoningOptions, - selectedModel, - selectedProviderDisplayName, - selectedProviderId, - serviceTier, - serviceTierSupportByProvider, - supportsServiceTier, - ], - ); - - const permissionConfig = useMemo( - () => ({ - // Pinned to the same constant the create request uses, so the displayed - // label can't drift from the side chat's actual (always read-only) reach. - value: SIDE_CHAT_PERMISSION_MODE, - options: permissionModeOptions, - onChange: noop, - supported: supportsPermissionModeSelection, - }), - [permissionModeOptions, supportsPermissionModeSelection], - ); - const environmentSummary = useMemo(() => { if (sourceEnvironment === null) { // Personal-project side chats inherit the parent's local workspace with no diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index c57d2ffc6..677c70ef4 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -93,7 +93,7 @@ type ReasoningLevelSelectionSetter = (value: ReasoningLevel) => void; type PermissionModeSelectionSetter = (value: PermissionMode) => void; type ClearSelectionHandler = () => void; -interface UseThreadCreationOptionsResult { +export interface UseThreadCreationOptionsResult { selectedProviderId: string; setSelectedProviderId: StringSelectionSetter; providerOptions: PickerOption[]; diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 9178eddad..ac3ad0f80 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -14,9 +14,8 @@ import { NewThreadPromptBox, type NewThreadProjectConfig, } from "@/components/promptbox/NewThreadPromptBox"; -import { withLoopPromptAction } from "@/components/promptbox/PromptBoxActionsMenu"; -import { buildProviderPromptActionProps } from "@/components/promptbox/mentions/command-trigger"; import { type PromptBoxHandle } from "@/components/promptbox/PromptBoxInternal"; +import { useComposerArea } from "@/components/promptbox/useComposerArea"; import { encodeHostValue, encodeReuseValue, @@ -27,7 +26,6 @@ import type { ProjectSelectorOption } from "@/components/pickers/ProjectSelector import type { ReuseThreadOption } from "@/components/pickers/WorktreePicker"; import { Icon } from "@/components/ui/icon.js"; import { PageShell } from "@/components/ui/page-shell.js"; -import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations"; import { useCreateThread } from "@/hooks/mutations/thread-runtime-mutations"; import { useProjectPromptHistory, @@ -37,15 +35,10 @@ import { import { useProjectDefaultExecutionOptions } from "@/hooks/queries/project-default-execution-options-query"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useThreads } from "@/hooks/queries/thread-queries"; -import { useCommandSuggestions } from "@/hooks/useCommandSuggestions"; import { usePrimaryHost } from "@/hooks/queries/host-queries"; -import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; import { useEscapeToHide } from "@/hooks/useEscapeToHide"; -import { usePromptMentions } from "@/hooks/usePromptMentions"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject"; -import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; -import { getMutationErrorMessage } from "@/lib/mutation-errors"; import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history"; import { getProjectScopedStorageKey } from "@/lib/project-scoped-storage"; import { promptDraftToInput } from "@/lib/prompt-draft"; @@ -402,27 +395,8 @@ export function RootComposeView(props: RootComposeViewProps) { readForkThreadCreateSeedFromLocationState(location.state), ); const primaryHostId = usePrimaryHost()?.id ?? null; - const uploadPromptAttachment = useUploadPromptAttachment(); - const promptDraft = usePromptDraftStorage({ kind: "new-thread" }); const { data: projectPromptHistory = [] } = useProjectPromptHistory(projectId); - const promptMentions = usePromptMentions( - isProjectless ? undefined : projectId, - { - environmentId: null, - }, - ); - const [attachmentError, setAttachmentError] = useState(null); - const prompt = promptDraft.text; - const promptInput = useMemo( - () => - promptDraftToInput({ - text: promptDraft.text, - mentions: promptDraft.mentions, - attachments: promptDraft.attachments, - }), - [promptDraft.attachments, promptDraft.mentions, promptDraft.text], - ); const rootComposeZenModeStorageKey = useMemo( () => getProjectScopedStorageKey(ROOT_COMPOSE_ZEN_MODE_STORAGE_KEY, projectId), @@ -460,44 +434,104 @@ export function RootComposeView(props: RootComposeViewProps) { currentProject?.defaultExecutionOptions ?? projectDefaultExecutionOptionsQuery.data ?? null; - const creationOptions = useThreadCreationOptions({ - scope: "new-thread", - initialProviderId: projectDefaultExecutionOptions?.providerId, - initialModel: projectDefaultExecutionOptions?.model, - initialServiceTier: projectDefaultExecutionOptions?.serviceTier, - initialReasoningLevel: projectDefaultExecutionOptions?.reasoningLevel, - initialPermissionMode: projectDefaultExecutionOptions?.permissionMode, + // Worktree picker options come from the project's unarchived threads. + // Threads on managed or unmanaged worktrees with a non-null environmentId + // contribute; envs with only archived threads disappear naturally. Resolved + // before the composer so command discovery can scope to a reused worktree. + const threadsQuery = useThreads( + { projectId, archived: false }, + { enabled: Boolean(projectId) }, + ); + const reuseThreadOptions = useMemo( + () => buildReuseThreadOptions(threadsQuery.data ?? []), + [threadsQuery.data], + ); + // The new-thread composer has no environment yet, so only thread mentions are + // openable here (they navigate). File pills stay non-interactive. + const resolveMentionLink = useCallback( + (resource) => + resource.kind === "thread" + ? () => + navigate( + getSurfaceAwareThreadRoutePath({ + projectId: resource.projectId ?? projectId, + surface: props.surface, + threadId: resource.threadId, + }), + ) + : null, + [navigate, projectId, props.surface], + ); + // Shared composer wiring. The new-thread box keeps its own submit handler, + // project / environment / branch / worktree pickers, and prompt-history + // surface; only the config assembly comes from the hook. Command discovery + // follows the resolved environment selection — which the hook itself produces + // — so it is supplied as a resolver over the live selection value. + const composerArea = useComposerArea({ + creationOptions: { + scope: "new-thread", + initialProviderId: projectDefaultExecutionOptions?.providerId, + initialModel: projectDefaultExecutionOptions?.model, + initialServiceTier: projectDefaultExecutionOptions?.serviceTier, + initialReasoningLevel: projectDefaultExecutionOptions?.reasoningLevel, + initialPermissionMode: projectDefaultExecutionOptions?.permissionMode, + }, + draftScope: { kind: "new-thread" }, + mentionsProjectId: isProjectless ? undefined : projectId, + mentions: { environmentId: null }, + commands: { + projectId, + environmentId: (environmentSelectionValue) => { + const effective = resolveRootComposeEffectiveEnvironmentValue({ + environmentSelectionValue, + isProjectless, + primaryHostId, + projectSources, + reuseThreadOptions, + reuseThreadOptionsLoading: threadsQuery.isLoading, + }); + const parsed = parseEnvironmentValue(effective); + return parsed?.type === "reuse" ? parsed.environmentId : null; + }, + }, + resolveMentionLink, + attachments: { projectId, errorMode: "first" }, + execution: { providerSwitchable: forkSeed === null }, + permission: { kind: "editable" }, }); const { - selectedProviderId, - setSelectedProviderId, - providerOptions, - hasMultipleProviders, - selectedProviderComposerActions, - selectedModel, - setSelectedModel, - serviceTier, - setServiceTier, - reasoningLevel, - setReasoningLevel, - permissionMode, - setPermissionMode, - environmentSelectionValue, - setEnvironmentSelectionValue, - clearReuseEnvironment, + attachmentsConfig, + composer, + executionConfig, + permissionConfig, + promptDraft, + providerPromptActionProps, + setAttachmentError, + threadCreationOptions, + typeaheadConfig, + } = composerArea; + const { activeModel, - modelOptions, - moreModelOptions, + clearReuseEnvironment, + environmentSelectionValue, + executionInputSources, isLoadingModels, - modelLoadFailed, modelLoadError, - reasoningOptions, - permissionModeOptions, - supportsPermissionModeSelection, + permissionMode, + reasoningLevel, + selectedModel, + selectedProviderId, + serviceTier, + setEnvironmentSelectionValue, + setPermissionMode, + setReasoningLevel, + setSelectedModel, + setSelectedProviderId, + setServiceTier, supportsServiceTier, - serviceTierSupportByProvider, - } = creationOptions; - const executionInputSources = creationOptions.executionInputSources; + } = threadCreationOptions; + const prompt = composer.message; + const promptInput = composer.currentPromptDraftInput; // Seed transient picker state from navigation state: `reuseEnvironmentId` // (the "+" affordance on a worktree) seeds the env picker into reuse mode for @@ -554,17 +588,6 @@ export function RootComposeView(props: RootComposeViewProps) { }); }, [location.search, location.state, navigate, seedInitialPrompt]); - // Worktree picker options come from the project's unarchived threads. - // Threads on managed or unmanaged worktrees with a non-null environmentId - // contribute; envs with only archived threads disappear naturally. - const threadsQuery = useThreads( - { projectId, archived: false }, - { enabled: Boolean(projectId) }, - ); - const reuseThreadOptions = useMemo( - () => buildReuseThreadOptions(threadsQuery.data ?? []), - [threadsQuery.data], - ); const mobileRecentThreads = useMemo( () => buildMobileRecentThreads({ @@ -776,32 +799,6 @@ export function RootComposeView(props: RootComposeViewProps) { return () => window.cancelAnimationFrame(handle); }, [location.key, shouldFocusPrompt]); - const handleAttachFiles = useCallback( - async (files: File[]) => { - if (!projectId || files.length === 0) return; - - setAttachmentError(null); - for (const file of files) { - try { - const uploaded = await uploadPromptAttachment.mutateAsync({ - projectId, - file, - }); - promptDraft.addAttachment(uploaded); - } catch (err) { - setAttachmentError( - getMutationErrorMessage({ - error: err, - fallbackMessage: "Attachment upload failed", - }), - ); - break; - } - } - }, - [projectId, promptDraft, uploadPromptAttachment], - ); - const submitPrompt = useCallback(async () => { const submittedDraft = { text: promptDraft.text, @@ -891,6 +888,7 @@ export function RootComposeView(props: RootComposeViewProps) { selectedProviderId, selectedThreadModel, serviceTier, + setAttachmentError, supportsServiceTier, ]); @@ -922,14 +920,7 @@ export function RootComposeView(props: RootComposeViewProps) { onHide: hideEmptyPopoutPrompt, }); - const currentPromptDraft = useMemo( - () => ({ - text: promptDraft.text, - mentions: promptDraft.mentions, - attachments: promptDraft.attachments, - }), - [promptDraft.attachments, promptDraft.mentions, promptDraft.text], - ); + const currentPromptDraft = composer.currentPromptDraft; const historyConfig = useMemo( () => ({ currentDraft: currentPromptDraft, @@ -939,154 +930,6 @@ export function RootComposeView(props: RootComposeViewProps) { }), [currentPromptDraft, projectId, promptDraft.setDraft, promptHistoryDrafts], ); - // The new-thread composer has no environment yet, so only thread mentions are - // openable here (they navigate). File pills stay non-interactive. - const resolveMentionLink = useCallback( - (resource) => - resource.kind === "thread" - ? () => - navigate( - getSurfaceAwareThreadRoutePath({ - projectId: resource.projectId ?? projectId, - surface: props.surface, - threadId: resource.threadId, - }), - ) - : null, - [navigate, projectId, props.surface], - ); - // Mirrors the @-mention plumbing: the composer feeds the text typed after the - // command trigger into `commandQuery`, which drives command typeahead. In - // projectless compose, the server resolves the personal project to user-home - // command discovery with cwd: null. - const [commandQuery, setCommandQuery] = useState(null); - const providerPromptActions = useMemo( - () => buildProviderPromptActionProps(selectedProviderComposerActions), - [selectedProviderComposerActions], - ); - const providerPromptActionProps = useMemo( - () => ({ - promptActions: withLoopPromptAction(providerPromptActions.promptActions), - }), - [providerPromptActions.promptActions], - ); - const reuseEnvironmentId = - parsedEnvironment?.type === "reuse" - ? parsedEnvironment.environmentId - : null; - const commandSuggestions = useCommandSuggestions({ - projectId, - providerId: selectedProviderId, - skillsTrigger: providerPromptActions.skillsTrigger, - environmentId: reuseEnvironmentId, - query: commandQuery, - }); - const typeaheadConfig = useMemo( - () => ({ - mention: { - suggestions: promptMentions.suggestions, - isLoading: promptMentions.isLoading, - isError: promptMentions.isError, - onQueryChange: promptMentions.setQuery, - resolveLink: resolveMentionLink, - }, - command: { - trigger: commandSuggestions.trigger, - suggestions: commandSuggestions.suggestions, - isLoading: commandSuggestions.isLoading, - isError: commandSuggestions.isError, - hasMore: commandSuggestions.hasMore, - isLoadingMore: commandSuggestions.isLoadingMore, - loadMore: commandSuggestions.loadMore, - onQueryChange: setCommandQuery, - }, - }), - [ - promptMentions.isError, - promptMentions.isLoading, - promptMentions.setQuery, - promptMentions.suggestions, - resolveMentionLink, - commandSuggestions.isError, - commandSuggestions.hasMore, - commandSuggestions.isLoading, - commandSuggestions.isLoadingMore, - commandSuggestions.loadMore, - commandSuggestions.suggestions, - commandSuggestions.trigger, - ], - ); - const attachmentsConfig = useMemo( - () => ({ - items: promptDraft.attachments, - projectId: projectId ?? "", - onAttachFiles: handleAttachFiles, - onRemove: promptDraft.removeAttachment, - isAttaching: uploadPromptAttachment.isPending, - error: attachmentError, - }), - [ - attachmentError, - handleAttachFiles, - projectId, - promptDraft.attachments, - promptDraft.removeAttachment, - uploadPromptAttachment.isPending, - ], - ); - const executionConfig = useMemo( - () => ({ - provider: { - options: providerOptions, - selectedId: selectedProviderId, - onChange: forkSeed === null ? setSelectedProviderId : undefined, - hasMultiple: hasMultipleProviders, - }, - model: { - active: activeModel, - selected: selectedModel, - options: modelOptions, - moreOptions: moreModelOptions, - isLoading: isLoadingModels, - loadFailed: modelLoadFailed, - loadError: modelLoadError, - onChange: setSelectedModel, - }, - serviceTier: { - value: serviceTier, - onChange: setServiceTier, - supported: supportsServiceTier, - supportByProvider: serviceTierSupportByProvider, - }, - reasoning: { - value: reasoningLevel, - options: reasoningOptions, - onChange: setReasoningLevel, - }, - }), - [ - activeModel, - forkSeed, - hasMultipleProviders, - isLoadingModels, - modelLoadFailed, - modelLoadError, - modelOptions, - moreModelOptions, - providerOptions, - reasoningLevel, - reasoningOptions, - selectedModel, - selectedProviderId, - serviceTier, - serviceTierSupportByProvider, - setReasoningLevel, - setSelectedModel, - setSelectedProviderId, - setServiceTier, - supportsServiceTier, - ], - ); const isForkDraft = forkSeed !== null; const environmentConfig = useMemo( () => ({ @@ -1182,20 +1025,6 @@ export function RootComposeView(props: RootComposeViewProps) { selectedBranch?.name, ], ); - const permissionConfig = useMemo( - () => ({ - value: permissionMode, - options: permissionModeOptions, - onChange: setPermissionMode, - supported: supportsPermissionModeSelection, - }), - [ - permissionMode, - permissionModeOptions, - setPermissionMode, - supportsPermissionModeSelection, - ], - ); const handleCancelForkDraft = useCallback(() => { setForkSeed(null); window.requestAnimationFrame(() => { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 804df02b6..1f6279f21 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -2,7 +2,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import type { IconName } from "@/components/ui/icon.js"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { getFollowUpPromptPlaceholder } from "@/components/promptbox/follow-up-placeholder"; -import { buildProviderPromptActionProps } from "@/components/promptbox/mentions/command-trigger"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; import type { EnvironmentStatus, @@ -44,12 +43,7 @@ import { import type { QueuedMessageReorderRequest } from "@/lib/queued-message-reorder"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; -import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; import { useEscapeToHide } from "@/hooks/useEscapeToHide"; -import { usePromptMentions } from "@/hooks/usePromptMentions"; -import { useCommandSuggestions } from "@/hooks/useCommandSuggestions"; -import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; -import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations"; import { useProjectDisplayName } from "@/hooks/queries/sidebar-navigation-query"; import { useCreateThreadQueuedMessage, @@ -67,13 +61,12 @@ import { import { useThreadDefaultExecutionOptions } from "@/hooks/queries/thread-default-execution-options-query"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history"; -import { promptDraftToInput } from "@/lib/prompt-draft"; import { appToast } from "@/components/ui/app-toast"; import { FollowUpPromptBox, type FollowUpSubmitMode, } from "@/components/promptbox/FollowUpPromptBox"; -import { withLoopPromptAction } from "@/components/promptbox/PromptBoxActionsMenu"; +import { useComposerArea } from "@/components/promptbox/useComposerArea"; import { queuedInputToDraft } from "./threadQueuedMessages"; import type { SendMessageMutationLike } from "./threadDetailMutationTypes"; import { @@ -288,26 +281,58 @@ export function ThreadDetailPromptArea({ const reorderQueuedMessage = useReorderThreadQueuedMessage(); const stopThread = useStopThread(); const unarchiveThread = useUnarchiveThread(); - const uploadPromptAttachment = useUploadPromptAttachment(); // 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, ); - const promptDraft = usePromptDraftStorage({ - kind: "thread", - projectId, - threadId: thread.id, - }); - const promptMentions = usePromptMentions(projectId, { - currentThreadId: thread.id, - environmentId: thread.environmentId ?? null, + // Shared composer wiring: thread creation options, the draft store, + // `@`-mention + command typeahead, attachment upload, and the assembled + // execution / permission / typeahead / attachments configs. The follow-up box + // keeps its own submit handlers, prompt-history surface, and queued-message + // stack below. + const composerArea = useComposerArea({ + creationOptions: { + enabled: composerQueriesEnabled, + environmentId: thread.environmentId ?? undefined, + scope: "component-local", + resetKey: thread.id, + initialProviderId: thread.providerId, + initialModel: defaultExecutionOptions?.model, + initialServiceTier: defaultExecutionOptions?.serviceTier, + initialReasoningLevel: defaultExecutionOptions?.reasoningLevel, + initialPermissionMode: defaultExecutionOptions?.permissionMode, + initialEnvironmentSelectionValue: thread.environmentId ?? undefined, + }, + draftScope: { kind: "thread", projectId, threadId: thread.id }, + mentionsProjectId: projectId, + mentions: { + currentThreadId: thread.id, + environmentId: thread.environmentId ?? null, + }, + commands: { + projectId: thread.projectId, + providerId: thread.providerId, + environmentId: thread.environmentId, + }, + resolveMentionLink, + attachments: { projectId }, + execution: {}, + permission: { + kind: "editable-gated", + resolved: hasConcreteDefaultExecutionOptions, + }, }); - // Mirrors the @-mention query plumbing above: the composer feeds the text - // typed after the command trigger into `commandQuery`, which drives the hook. - // Called unconditionally (hooks rules); inert when the provider has no - // command trigger. - const [commandQuery, setCommandQuery] = useState(null); - const [attachmentError, setAttachmentError] = useState(null); + const { + attachmentsConfig, + composer, + executionConfig, + permissionConfig, + promptDraft, + providerPromptActionProps, + setAttachmentError, + threadCreationOptions, + typeaheadConfig, + } = composerArea; const [expandedBannerSection, setExpandedBannerSection] = useState(null); const pullRequestSection = useMemo( @@ -357,61 +382,17 @@ export function ThreadDetailPromptArea({ () => promptHistoryEntriesToDrafts(promptHistoryEntries), [promptHistoryEntries], ); + // Execution selection fields the follow-up submit path reads directly (the + // execution/permission pickers themselves are wired by useComposerArea above). const { - selectedProviderId, - providerOptions, - hasMultipleProviders, - selectedProviderDisplayName, - selectedProviderComposerActions, + activeModel, + executionInputSources, + permissionMode, + reasoningLevel, selectedModel, - setSelectedModel, serviceTier, - setServiceTier, - reasoningLevel, - setReasoningLevel, - permissionMode, - setPermissionMode, - activeModel, - modelOptions, - moreModelOptions, - isLoadingModels, - modelLoadFailed, - modelLoadError, - reasoningOptions, - permissionModeOptions, - supportsPermissionModeSelection, supportsServiceTier, - serviceTierSupportByProvider, - executionInputSources, - } = useThreadCreationOptions({ - enabled: composerQueriesEnabled, - environmentId: thread.environmentId ?? undefined, - scope: "component-local", - resetKey: thread.id, - initialProviderId: thread.providerId, - initialModel: defaultExecutionOptions?.model, - initialServiceTier: defaultExecutionOptions?.serviceTier, - initialReasoningLevel: defaultExecutionOptions?.reasoningLevel, - initialPermissionMode: defaultExecutionOptions?.permissionMode, - initialEnvironmentSelectionValue: thread.environmentId ?? undefined, - }); - const providerPromptActions = useMemo( - () => buildProviderPromptActionProps(selectedProviderComposerActions), - [selectedProviderComposerActions], - ); - const providerPromptActionProps = useMemo( - () => ({ - promptActions: withLoopPromptAction(providerPromptActions.promptActions), - }), - [providerPromptActions.promptActions], - ); - const commandSuggestions = useCommandSuggestions({ - projectId: thread.projectId, - providerId: thread.providerId, - skillsTrigger: providerPromptActions.skillsTrigger, - environmentId: thread.environmentId, - query: commandQuery, - }); + } = threadCreationOptions; const runtimeDisplayStatus = thread.runtime.displayStatus; const isStopRequested = thread.status === "stopping" || @@ -452,19 +433,8 @@ export function ThreadDetailPromptArea({ const promptPlaceholder = isStopRequested ? "Stopping thread..." : getFollowUpPromptPlaceholder(runtimeDisplayStatus); - 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 hasPromptDraftInput = currentPromptDraftInput.length > 0; + const { currentPromptDraft, currentPromptDraftInput, hasPromptDraftInput } = + composer; const isPromptEmpty = useCallback( () => !hasPromptDraftInput, [hasPromptDraftInput], @@ -508,32 +478,6 @@ export function ThreadDetailPromptArea({ supportsServiceTier, ]); - const handleAttachFiles = useCallback( - async (files: File[]) => { - if (files.length === 0) { - return; - } - - setAttachmentError(null); - const failedFiles: string[] = []; - for (const file of files) { - try { - const uploaded = await uploadPromptAttachment.mutateAsync({ - projectId, - file, - }); - promptDraft.addAttachment(uploaded); - } catch { - failedFiles.push(file.name); - } - } - if (failedFiles.length > 0) { - setAttachmentError(`Failed to attach: ${failedFiles.join(", ")}`); - } - }, - [projectId, promptDraft, uploadPromptAttachment], - ); - const handleSend = useCallback(async () => { const submittedDraft = currentPromptDraft; const submittedInput = currentPromptDraftInput; @@ -590,6 +534,7 @@ export function ThreadDetailPromptArea({ isDefaultExecutionOptionsLoading, promptDraft, sendMessage, + setAttachmentError, thread.id, runtimeDisplayStatus, ]); @@ -633,7 +578,7 @@ export function ThreadDetailPromptArea({ ); } }, - [sendQueuedMessage, thread.id], + [sendQueuedMessage, setAttachmentError, thread.id], ); const handleModifierSubmit = useCallback(async () => { @@ -695,6 +640,7 @@ export function ThreadDetailPromptArea({ promptDraft, sendMessage, sendQueuedMessageById, + setAttachmentError, thread.id, ]); @@ -748,7 +694,7 @@ export function ThreadDetailPromptArea({ ); }); }, - [deleteQueuedMessage, promptDraft, thread.id], + [deleteQueuedMessage, promptDraft, setAttachmentError, thread.id], ); const handleDeleteQueuedMessage = useCallback( @@ -817,25 +763,6 @@ export function ThreadDetailPromptArea({ unarchiveThread.mutate({ id: thread.id }); }, [thread.id, unarchiveThread]); - const attachmentsConfig = useMemo( - () => ({ - items: promptDraft.attachments, - projectId, - isAttaching: uploadPromptAttachment.isPending, - error: attachmentError, - onAttachFiles: handleAttachFiles, - onRemove: promptDraft.removeAttachment, - }), - [ - attachmentError, - handleAttachFiles, - projectId, - promptDraft.attachments, - promptDraft.removeAttachment, - uploadPromptAttachment.isPending, - ], - ); - const composerConfig = useMemo( () => ({ history: { @@ -873,112 +800,6 @@ export function ThreadDetailPromptArea({ ], ); - const executionConfig = useMemo( - () => ({ - provider: { - options: providerOptions, - selectedId: selectedProviderId, - hasMultiple: hasMultipleProviders, - displayName: selectedProviderDisplayName, - }, - model: { - active: activeModel, - selected: selectedModel, - options: modelOptions, - moreOptions: moreModelOptions, - isLoading: isLoadingModels, - loadFailed: modelLoadFailed, - loadError: modelLoadError, - onChange: setSelectedModel, - }, - serviceTier: { - value: serviceTier, - onChange: setServiceTier, - supported: supportsServiceTier, - supportByProvider: serviceTierSupportByProvider, - }, - reasoning: { - value: reasoningLevel, - options: reasoningOptions, - onChange: setReasoningLevel, - }, - }), - [ - activeModel, - hasMultipleProviders, - isLoadingModels, - modelLoadFailed, - modelLoadError, - modelOptions, - moreModelOptions, - providerOptions, - reasoningLevel, - reasoningOptions, - selectedModel, - selectedProviderDisplayName, - selectedProviderId, - serviceTier, - serviceTierSupportByProvider, - setReasoningLevel, - setSelectedModel, - setServiceTier, - supportsServiceTier, - ], - ); - - const permissionConfig = useMemo( - () => ({ - value: hasConcreteDefaultExecutionOptions ? permissionMode : undefined, - options: hasConcreteDefaultExecutionOptions ? permissionModeOptions : [], - onChange: setPermissionMode, - supported: - hasConcreteDefaultExecutionOptions && supportsPermissionModeSelection, - }), - [ - hasConcreteDefaultExecutionOptions, - permissionMode, - permissionModeOptions, - setPermissionMode, - supportsPermissionModeSelection, - ], - ); - - const typeaheadConfig = useMemo( - () => ({ - mention: { - suggestions: promptMentions.suggestions, - isLoading: promptMentions.isLoading, - isError: promptMentions.isError, - onQueryChange: promptMentions.setQuery, - resolveLink: resolveMentionLink, - }, - command: { - trigger: commandSuggestions.trigger, - suggestions: commandSuggestions.suggestions, - isLoading: commandSuggestions.isLoading, - isError: commandSuggestions.isError, - hasMore: commandSuggestions.hasMore, - isLoadingMore: commandSuggestions.isLoadingMore, - loadMore: commandSuggestions.loadMore, - onQueryChange: setCommandQuery, - }, - }), - [ - promptMentions.isError, - promptMentions.isLoading, - promptMentions.setQuery, - promptMentions.suggestions, - resolveMentionLink, - commandSuggestions.isError, - commandSuggestions.hasMore, - commandSuggestions.isLoading, - commandSuggestions.isLoadingMore, - commandSuggestions.loadMore, - commandSuggestions.suggestions, - commandSuggestions.trigger, - ], - ); - const environmentSummary = useMemo( () => environmentLabel ? (