diff --git a/apps/app/src/app.css b/apps/app/src/app.css index c4f97cb613..88605fc955 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -468,6 +468,10 @@ padding: 0; } + [data-follow-up-composer] [data-promptbox-submit-label] { + display: none; + } + [data-follow-up-composer] [data-follow-up-composer-footer] { min-height: 0; max-height: 0; diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index df49e1baf1..7e6b49e045 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -25,6 +25,7 @@ import { import { buildModelNavRows, ModelReasoningPicker, + type ModelReasoningPickerHandoff, } from "./ModelReasoningPicker"; import type { PickerOption } from "./OptionPicker"; import type { ProviderPickerOption } from "./model-brand-prefix"; @@ -160,6 +161,7 @@ function renderPicker({ compact = false, splitPane = false, muted = false, + handoff, }: { onSelectedProviderChange?: ((value: string) => void) | null; onModelChange?: (value: string) => void; @@ -178,6 +180,7 @@ function renderPicker({ compact?: boolean; splitPane?: boolean; muted?: boolean; + handoff?: ModelReasoningPickerHandoff; } = {}) { const { queryClient, wrapper } = createQueryClientTestHarness(); queryClient.setQueryData( @@ -219,6 +222,7 @@ function renderPicker({ showFastModeToggle={false} muted={muted} modal={false} + handoff={handoff} /> @@ -667,6 +671,177 @@ describe("ModelReasoningPicker", () => { expect(onModelChange).toHaveBeenCalledWith("claude-opus-4-7"); }); + it("opens the same handoff flow from provider tabs and the footer", async () => { + const onSelect = vi.fn(); + const { onSelectedProviderChange, onModelChange, onReasoningChange } = + renderPicker({ handoff: { sourceProviderId: "codex", onSelect } }); + const trigger = screen.getByRole("button", { + name: "Provider, model and reasoning", + }); + + fireEvent.click(trigger); + fireEvent.click(screen.getByTitle("Claude Code")); + + expect( + screen.getByRole("button", { name: "Back to model picker" }), + ).not.toBeNull(); + fireEvent.click(screen.getByTitle("Codex (current thread)")); + expect( + screen.queryByRole("button", { name: "Back to model picker" }), + ).toBeNull(); + expect(onSelectedProviderChange).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); + expect(screen.getByTitle("Codex (current thread)")).not.toBeNull(); + expect(await screen.findByText("Opus 4.7")).not.toBeNull(); + expect(screen.getAllByText("5.5")).toHaveLength(1); + expect(onSelectedProviderChange).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { name: "Handoff to new thread" }), + ).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Back to model picker" }), + ); + expect( + screen.queryByRole("button", { name: "Back to model picker" }), + ).toBeNull(); + expect(screen.getByTitle("Codex")).toHaveProperty("disabled", false); + expect(screen.getAllByText("5.5")).toHaveLength(2); + + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); + fireEvent.click(await screen.findByText("Opus 4.7")); + + expect(onSelect).toHaveBeenCalledExactlyOnceWith({ + providerId: "claude-code", + model: "claude-opus-4-7", + reasoningLevel: "medium", + }); + expect(onSelectedProviderChange).not.toHaveBeenCalled(); + expect(onModelChange).not.toHaveBeenCalled(); + expect(onReasoningChange).not.toHaveBeenCalled(); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect( + screen.getByRole("button", { name: "Back to model picker" }), + ).not.toBeNull(); + }); + + it.each([ + ["modelPicker.cycleModel", "claude-sonnet-4-6"], + ["modelPicker.cycleModelBackward", "claude-haiku-4-5"], + ])( + "%s selects from the handoff provider without changing the source", + async (command, expectedModel) => { + const onSelect = vi.fn(); + const { onModelChange, onReasoningChange, onSelectedProviderChange } = + renderPicker({ + handoff: { sourceProviderId: "codex", onSelect }, + providerRouting: { environmentId: "env-source" }, + alternateProviderModels: [ + availableModel({ + value: "claude-opus-4-7", + label: "Claude Opus 4.7", + isDefault: true, + }), + availableModel({ + value: "claude-sonnet-4-6", + label: "Claude Sonnet 4.6", + }), + availableModel({ + value: "claude-haiku-4-5", + label: "Claude Haiku 4.5", + }), + ], + }); + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); + await screen.findByText("Opus 4.7"); + act(() => { + commandHandlers.get(command)?.({ target: document.body }); + }); + expect(onSelect).toHaveBeenCalledExactlyOnceWith({ + providerId: "claude-code", + model: expectedModel, + reasoningLevel: "medium", + }); + expect(onModelChange).not.toHaveBeenCalled(); + expect(onReasoningChange).not.toHaveBeenCalled(); + expect(onSelectedProviderChange).not.toHaveBeenCalled(); + }, + ); + + it.each(["modelPicker.cycleReasoning", "modelPicker.cycleReasoningBackward"])( + "%s keeps the handoff preview and applies its reasoning to selection", + async (command) => { + const onSelect = vi.fn(); + const { onModelChange, onReasoningChange } = renderPicker({ + handoff: { sourceProviderId: "codex", onSelect }, + alternateProviderModels: [ + { + ...availableModel({ + value: "claude-opus-4-7", + label: "Claude Opus 4.7", + isDefault: true, + }), + supportedReasoningEfforts: [ + { reasoningEffort: "medium", description: "Medium" }, + { reasoningEffort: "high", description: "High" }, + ], + }, + ], + }); + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); + await screen.findByText("Opus 4.7"); + act(() => { + commandHandlers.get(command)?.({ target: document.body }); + }); + expect(screen.getByTitle("Codex (current thread)")).not.toBeNull(); + fireEvent.click(screen.getByText("Opus 4.7")); + expect(onSelect).toHaveBeenCalledExactlyOnceWith({ + providerId: "claude-code", + model: "claude-opus-4-7", + reasoningLevel: "high", + }); + expect(onModelChange).not.toHaveBeenCalled(); + expect(onReasoningChange).not.toHaveBeenCalled(); + }, + ); + + it("returns to the thread's provider from the current-thread tab", () => { + const onSelect = vi.fn(); + const { onSelectedProviderChange } = renderPicker({ + selectedProviderId: "claude-code", + handoff: { sourceProviderId: "codex", onSelect }, + }); + + fireEvent.click( + screen.getByRole("button", { name: "Provider, model and reasoning" }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); + fireEvent.click(screen.getByTitle("Codex (current thread)")); + + expect(onSelectedProviderChange).toHaveBeenCalledExactlyOnceWith("codex"); + expect(onSelect).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { name: "Back to model picker" }), + ).toBeNull(); + }); + it.each([ { label: "fetches every sibling", diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index d8d63a7e98..2916dfac38 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -97,6 +97,17 @@ interface ResolvedProviderPreview { supportsServiceTier: boolean; } +export interface ModelReasoningPickerHandoffSelection { + providerId: string; + model: string; + reasoningLevel: ReasoningLevel; +} + +export interface ModelReasoningPickerHandoff { + sourceProviderId: string; + onSelect: (selection: ModelReasoningPickerHandoffSelection) => void; +} + const FAILED_TO_LOAD_MODELS_LABEL = "Failed to load models"; const EMPTY_MODEL_OPTIONS: readonly ModelPickerOption[] = []; const preserveModelLabel = (displayName: string): string => displayName; @@ -116,6 +127,9 @@ const REASONING_CYCLE_COMMANDS = [ const MODEL_SEARCH_MIN_OPTIONS = 5; const MODEL_PICKER_MENU_WIDTH_CLASS_NAME = "w-max min-w-64 max-w-80"; +const HANDOFF_DRAWER_TOP_CLASS_NAME = + "[&>[data-persistent-drawer-handle]]:w-full [&>[data-persistent-drawer-handle]]:rounded-t-xl [&>[data-persistent-drawer-handle]]:bg-background"; + function splitModelLabelTag(label: string): ModelLabelParts { const match = label.match(/^(.*\S)\s*\(([^()]+)\)$/u); if (!match) { @@ -194,15 +208,7 @@ interface ModelReasoningPickerProps { modal?: boolean; align?: "start" | "center" | "end"; disabled?: boolean; - footerAction?: ModelReasoningPickerFooterAction; -} - -export interface ModelReasoningPickerFooterAction { - label: string; - onClick: () => void; - disabled?: boolean; - title?: string; - iconName?: IconName; + handoff?: ModelReasoningPickerHandoff; } export function ModelReasoningPicker({ @@ -235,7 +241,7 @@ export function ModelReasoningPicker({ modal = true, align = "start", disabled, - footerAction, + handoff, }: ModelReasoningPickerProps) { const isCompactViewport = useIsCompactViewport(); const [open, setOpen] = useState(false); @@ -260,9 +266,18 @@ export function ModelReasoningPicker({ const [moreModelsOpen, setMoreModelsOpen] = useState(false); const [trackedSelectedProviderId, setTrackedSelectedProviderId] = useState(selectedProviderId); + const [handoffMode, setHandoffMode] = useState(false); + const [handoffReasoningLevel, setHandoffReasoningLevel] = + useState(null); if (trackedSelectedProviderId !== selectedProviderId) { setTrackedSelectedProviderId(selectedProviderId); + setHandoffMode( + open && + handoff !== undefined && + selectedProviderId !== handoff.sourceProviderId, + ); + setHandoffReasoningLevel(null); setPreviewProviderId(null); setShowMoreModels(false); setMoreModelsOpen(false); @@ -427,6 +442,13 @@ export function ModelReasoningPicker({ const activeReasoningOptions = isPreviewing ? (previewSelection?.reasoningOptions ?? []) : reasoningOptions; + const activeReasoningValue: ReasoningLevel | "" = handoffMode + ? (handoffReasoningLevel ?? + (isPreviewing ? previewSelection?.reasoningLevel : reasoningValue) ?? + "") + : isPreviewing + ? "" + : reasoningValue; const activeModelLoadError = isPreviewing ? (previewQuery.data?.modelLoadError ?? null) : (modelLoadError ?? null); @@ -461,7 +483,8 @@ export function ModelReasoningPicker({ const isShowingModelError = !activeModelIsLoading && !hasActiveModelOptions && activeModelLoadFailed; const showProviderTabs = - canSwitchProviders && + (handoffMode || canSwitchProviders) && + providerOptions.length > 1 && (!isShowingModelError || activeModelErrorIsProviderSpecific); const activeBrandPrefix = activeProvider?.brandPrefix; @@ -512,6 +535,7 @@ export function ModelReasoningPicker({ activeIndex >= 0 && activeIndex < navRows.length ? activeIndex : -1; const effectiveShowFastModeToggle = + !handoffMode && hasActiveModelOptions && (serviceTierSupportByProvider ? (serviceTierSupportByProvider[activeProviderId] ?? false) @@ -530,6 +554,8 @@ export function ModelReasoningPicker({ : hasSelectedModel && !modelIsLoading && !selectedModelLoadFailed); const resetBrowseState = useCallback(() => { + setHandoffMode(false); + setHandoffReasoningLevel(null); setPreviewProviderId(null); setShowMoreModels(false); setMoreModelsOpen(false); @@ -552,15 +578,68 @@ export function ModelReasoningPicker({ const handleModelSelect = useCallback( (model: string) => { if (previewSelectionBlocked) return; + if (handoff !== undefined && handoffMode) { + handoff.onSelect({ + providerId: activeProviderId, + model, + reasoningLevel: + handoffReasoningLevel ?? + (isPreviewing ? previewSelection?.reasoningLevel : undefined) ?? + reasoningValue, + }); + setMoreModelsOpen(false); + return; + } onModelChange(model); setMoreModelsOpen(false); setPreviewProviderId(null); }, - [onModelChange, previewSelectionBlocked], + [ + activeProviderId, + handoff, + handoffMode, + handoffReasoningLevel, + isPreviewing, + onModelChange, + previewSelection, + previewSelectionBlocked, + reasoningValue, + ], ); + const handoffProviderOptions = useMemo( + () => + handoff === undefined + ? providerOptions + : providerOptions.filter( + (provider) => provider.value !== handoff.sourceProviderId, + ), + [handoff, providerOptions], + ); + const handleHandoffProviderSelect = useCallback( + (providerId: string) => { + setHandoffMode(true); + setPreviewProviderId( + providerId === selectedProviderId ? null : providerId, + ); + setHandoffReasoningLevel(null); + setShowMoreModels(false); + setMoreModelsOpen(false); + setSearchQuery(""); + setActiveIndex(-1); + }, + [selectedProviderId], + ); const handleProviderSelect = useCallback( (providerId: string) => { + if ( + open && + handoff !== undefined && + providerId !== handoff.sourceProviderId + ) { + handleHandoffProviderSelect(providerId); + return; + } onSelectedProviderChange?.(providerId); const nextPreviewProviderId = open && providerId !== selectedProviderId ? providerId : null; @@ -568,7 +647,60 @@ export function ModelReasoningPicker({ setSearchQuery(""); setActiveIndex(-1); }, - [onSelectedProviderChange, open, selectedProviderId], + [ + handoff, + handleHandoffProviderSelect, + onSelectedProviderChange, + open, + selectedProviderId, + ], + ); + const startHandoffMode = useCallback(() => { + const firstProvider = handoffProviderOptions[0]; + handleHandoffProviderSelect(firstProvider?.value ?? selectedProviderId); + }, [handleHandoffProviderSelect, handoffProviderOptions, selectedProviderId]); + const exitHandoffMode = useCallback(() => { + setHandoffMode(false); + setHandoffReasoningLevel(null); + setPreviewProviderId(null); + setSearchQuery(""); + setActiveIndex(-1); + }, []); + const returnToSourceProvider = useCallback(() => { + if (handoff === undefined) { + return; + } + exitHandoffMode(); + if (selectedProviderId !== handoff.sourceProviderId) { + onSelectedProviderChange?.(handoff.sourceProviderId); + } + }, [exitHandoffMode, handoff, onSelectedProviderChange, selectedProviderId]); + + const handleReasoningSelect = useCallback( + (level: ReasoningLevel) => { + if (previewSelectionBlocked) return; + if (handoffMode) { + setHandoffReasoningLevel(level); + if (!isPreviewing) { + onReasoningChange(level); + } + return; + } + if (isPreviewing && previewSelection?.selectedModel) { + onModelChange(previewSelection.selectedModel); + } + onReasoningChange(level); + setPreviewProviderId(null); + setMoreModelsOpen(false); + }, + [ + handoffMode, + isPreviewing, + previewSelection, + onModelChange, + onReasoningChange, + previewSelectionBlocked, + ], ); const paneContext = useOptionalPaneContext(); @@ -631,13 +763,22 @@ export function ModelReasoningPicker({ MODEL_CYCLE_COMMANDS, (index, { target }) => { if (!ownsCycleChord(target)) return false; + const options = handoffMode ? activeModelOptions : modelOptions; + const value = + handoffMode && isPreviewing + ? (previewSelection?.selectedModel ?? "") + : modelValue; const next = index === 0 - ? nextCycleValue(modelOptions, modelValue) - : previousCycleValue(modelOptions, modelValue); + ? nextCycleValue(options, value) + : previousCycleValue(options, value); if (next !== null) { - onModelChange(next); - setPreviewProviderId(null); + if (handoffMode) { + handleModelSelect(next); + } else { + onModelChange(next); + setPreviewProviderId(null); + } } return true; }, @@ -648,6 +789,16 @@ export function ModelReasoningPicker({ PROVIDER_CYCLE_COMMANDS, (index, { target }) => { if (!ownsCycleChord(target)) return false; + if (handoffMode) { + const next = + index === 0 + ? nextCycleValue(handoffProviderOptions, activeProviderId) + : previousCycleValue(handoffProviderOptions, activeProviderId); + if (next !== null) { + handleHandoffProviderSelect(next); + } + return true; + } if (canSwitchProviders && onSelectedProviderChange !== undefined) { const next = index === 0 @@ -666,39 +817,26 @@ export function ModelReasoningPicker({ REASONING_CYCLE_COMMANDS, (index, { target }) => { if (!ownsCycleChord(target)) return false; + const value = handoffMode ? activeReasoningValue : reasoningValue; + if (value === "") return true; const next = cycleReasoningValue( - reasoningOptions, - reasoningValue, + handoffMode ? activeReasoningOptions : reasoningOptions, + value, index === 0 ? "forward" : "backward", ); if (next !== null) { - onReasoningChange(next); - setPreviewProviderId(null); + if (handoffMode) { + handleReasoningSelect(next); + } else { + onReasoningChange(next); + setPreviewProviderId(null); + } } return true; }, 50, commandShortcutsEnabled, ); - const handleReasoningSelect = useCallback( - (level: ReasoningLevel) => { - if (previewSelectionBlocked) return; - if (isPreviewing && previewSelection?.selectedModel) { - onModelChange(previewSelection.selectedModel); - } - onReasoningChange(level); - setPreviewProviderId(null); - setMoreModelsOpen(false); - }, - [ - isPreviewing, - previewSelection, - onModelChange, - onReasoningChange, - previewSelectionBlocked, - ], - ); - const handleReasoningArrowKeyDown: KeyboardEventHandler = ( event, ) => { @@ -721,11 +859,8 @@ export function ModelReasoningPicker({ ) { return; } - const value = isPreviewing - ? previewSelection?.reasoningLevel - : reasoningValue; const index = activeReasoningOptions.findIndex( - (option) => option.value === value, + (option) => option.value === activeReasoningValue, ); if (index < 0) return; event.preventDefault(); @@ -735,16 +870,6 @@ export function ModelReasoningPicker({ if (next) handleReasoningSelect(next.value); }; - const handleFooterActionClick = useCallback(() => { - if (!footerAction || footerAction.disabled) { - return; - } - footerAction.onClick(); - setOpen(false); - setPreviewProviderId(null); - setMoreModelsOpen(false); - }, [footerAction]); - const handleQueryChange = useCallback((value: string) => { setSearchQuery(value); setActiveIndex(-1); @@ -923,7 +1048,10 @@ export function ModelReasoningPicker({ {trigger} + {handoffMode ? : null} {showProviderTabs ? (
{providerOptions.map((provider) => { const TabIcon = provider.icon; const isActive = provider.value === activeProviderId; + const isHandoffSource = + handoffMode && + handoff !== undefined && + provider.value === handoff.sourceProviderId; return ( + + Handoff to new thread + +
+ ); +} + function MenuSectionLabel({ children, className, @@ -1435,14 +1595,10 @@ function MenuRowButton({ function MenuActionButton({ label, iconName, - disabled, - title, onClick, }: { label: string; iconName: IconName; - disabled?: boolean; - title?: string; onClick: () => void; }) { const { hoverProps } = useMenuItemHover(); @@ -1450,11 +1606,9 @@ function MenuActionButton({ return ( ); @@ -1186,6 +1200,8 @@ export function PromptBoxInternal({ isSubmitting = false, disabled: submitDisabled = false, disabledReason: submitDisabledReason, + label: submitLabel, + icon: submitIcon, title: submitTitle = "Submit (Enter)", isRunning = false, onStop, @@ -3355,6 +3371,8 @@ export function PromptBoxInternal({ ) : ( { selectedProviderId: string; setSelectedProviderId: StringSelectionSetter; setProviderModelReasoning: ProviderModelReasoningSelectionSetter; + providers: readonly ProviderInfo[]; providerOptions: ProviderPickerOption[]; hasMultipleProviders: boolean; selectedProviderDisplayName: string; @@ -899,6 +900,7 @@ export function useThreadCreationOptions( selectedProviderId: effectiveProviderId, setSelectedProviderId, setProviderModelReasoning, + providers, providerOptions, hasMultipleProviders, selectedProviderDisplayName: diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index f09b71f8a6..dc13448568 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -19,7 +19,6 @@ import { type ResolveNewThreadSubmitDisabledReasonArgs, } from "@/components/promptbox/NewThreadComposer"; import { getProjectStoredPromptAttachmentPaths } from "@bb/client-core"; -import { THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY } from "@bb/client-core"; import { buildRootComposeTerminalSessions, buildMobileRecentThreads, @@ -726,19 +725,6 @@ describe("hasSingleUseRootComposeTargetState", () => { ); }); - it("treats handoff seeds as single-use target state", () => { - expect( - hasSingleUseRootComposeTargetState({ - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: { - environmentId: "env_source", - projectId: "proj_source", - sourceThreadId: "thr_source", - sourceThreadTitle: "Source thread", - }, - }), - ).toBe(true); - }); - it("ignores non-target state", () => { expect(hasSingleUseRootComposeTargetState(null)).toBe(false); }); diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 4224abc677..3364949d89 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -88,10 +88,6 @@ import { FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY, type ForkThreadCreateSeed, } from "@bb/client-core"; -import { - buildThreadHandoffPromptDraft, - readThreadHandoffCreateSeedFromLocationState, -} from "@bb/client-core"; import { useNavigateToThreadAfterCreatePreference } from "@/lib/root-compose-create-preference"; import { readInitialPromptFromSearch, @@ -403,8 +399,7 @@ export function hasSingleUseRootComposeTargetState(state: unknown): boolean { return ( readRootComposeSectionTargetFromLocationState(state) !== null || readReuseEnvironmentIdFromLocationState(state) !== null || - readForkThreadCreateSeedFromLocationState(state) !== null || - readThreadHandoffCreateSeedFromLocationState(state) !== null + readForkThreadCreateSeedFromLocationState(state) !== null ); } @@ -750,9 +745,6 @@ function RootComposeSurface({ const nextForkSeed = readForkThreadCreateSeedFromLocationState( location.state, ); - const nextHandoffSeed = readThreadHandoffCreateSeedFromLocationState( - location.state, - ); if (!hasSingleUseRootComposeTargetState(location.state)) return; if (shouldStartComposingFromLocationState(location.state)) { setStartedComposing(true); @@ -765,7 +757,7 @@ function RootComposeSurface({ if (reuseEnvironmentId !== null) { seedEnvironmentSelectionValue(encodeReuseValue(reuseEnvironmentId)); } - if (nextForkSeed !== null && nextHandoffSeed === null) { + if (nextForkSeed !== null) { setForkSeed(nextForkSeed); setRootComposeProjectId(nextForkSeed.projectId); setProviderModelReasoning(nextForkSeed); @@ -775,17 +767,6 @@ function RootComposeSurface({ encodeReuseValue(nextForkSeed.environmentId), ); } - if (nextHandoffSeed !== null) { - setStartedComposing(true); - setRootComposeProjectId(nextHandoffSeed.projectId); - setForkSeed(null); - if (nextHandoffSeed.environmentId !== null) { - seedEnvironmentSelectionValue( - encodeReuseValue(nextHandoffSeed.environmentId), - ); - } - setPromptDraft(buildThreadHandoffPromptDraft(nextHandoffSeed)); - } navigate(getRootComposeRoutePath() + location.search, { replace: true, state: null, @@ -797,7 +778,6 @@ function RootComposeSurface({ seedEnvironmentSelectionValue, setForkSeed, setPermissionMode, - setPromptDraft, setProviderModelReasoning, setRootComposeProjectId, setRootComposeSectionId, diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx index 1341a9db00..7dd7a336ea 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -190,6 +190,7 @@ vi.mock("@/hooks/useThreadCreationOptions", () => ({ moreModelOptions: [], permissionMode: "auto", permissionModeOptions: [], + providers: [], providerOptions: [], reasoningLevel: "medium", reasoningOptions: [], @@ -225,6 +226,7 @@ vi.mock("@/hooks/mutations/thread-runtime-mutations", () => { return { useCancelThreadPlan: idleMutation, useClearThreadGoal: idleMutation, + useCreateThread: idleMutation, useCreateThreadQueuedMessage: idleMutation, useDeleteThreadQueuedMessage: idleMutation, useReorderThreadQueuedMessage: idleMutation, diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index b07c01ac7e..ab71a5bfe3 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -3,6 +3,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { PendingInteraction, + PermissionMode, + PromptTextMention, ResolvedThreadExecutionOptions, ThreadQueuedMessage, ThreadTimelineActivePromptMode, @@ -28,8 +30,9 @@ import { import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { workflowRow } from "@/test/fixtures/thread-timeline-rows"; -import { THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY } from "@bb/client-core"; +import type { PromptDraftAttachment } from "@bb/client-core"; import { BbHttpError } from "@/lib/sdk"; +import type { TypeaheadConfig } from "@/components/promptbox/PromptBoxInternal"; import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host"; import { setComposerTextEffect } from "@/lib/composer-text-effects"; import { @@ -47,16 +50,17 @@ const mocks = vi.hoisted(() => ({ cancelThreadPlanMutate: vi.fn(), clearThreadGoalMutate: vi.fn(), createQueuedMessageMutateAsync: vi.fn(), + createThreadMutateAsync: vi.fn(), defaultExecutionOptions: null as ResolvedThreadExecutionOptions | null, deleteQueuedMessageMutateAsync: vi.fn(), navigate: vi.fn(), pluginComposerHost: null as PluginComposerHost | null, promptDraft: { addAttachment: vi.fn(), - attachments: [], + attachments: [] as PromptDraftAttachment[], clearIfCurrentMatches: vi.fn(), getCurrent: vi.fn(), - mentions: [], + mentions: [] as PromptTextMention[], removeAttachment: vi.fn(), restoreIfEmpty: vi.fn(), setDraft: vi.fn(), @@ -67,6 +71,7 @@ const mocks = vi.hoisted(() => ({ }, queuedMessages: [] as ThreadQueuedMessage[] | undefined, reorderQueuedMessageMutateAsync: vi.fn(), + sendMessageMutateAsync: vi.fn(), sendQueuedMessageMutateAsync: vi.fn(), setQueuedMessageGroupBoundaryMutateAsync: vi.fn(), stopThreadMutate: vi.fn(), @@ -94,6 +99,8 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { >("@/components/plugin/PluginComposerBanners"); return { FollowUpPromptBox: ({ + activePromptMode, + typeahead, attachments, composer, environmentSummary, @@ -108,6 +115,8 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { suppressPluginComposerCustomizations, textEffects, }: { + activePromptMode?: ThreadTimelineActivePromptMode | null; + typeahead: TypeaheadConfig; attachments: { items: readonly unknown[]; onAttachFiles: (files: File[]) => void | Promise; @@ -117,18 +126,28 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { onChangeMessage: (message: string, mentions: []) => void; onEscape?: () => void; onSubmit: () => void; + submitLabel?: string; + submitIcon?: string; submitTitle?: string; submitMode: { kind: string; reason?: string }; } | null; environmentSummary?: ReactNode; execution: { - footerAction?: { - label: string; - onClick: () => void; - }; + providerRouting: { environmentId?: string; hostId?: string }; model: { active?: { model: string } | null; }; + provider: { + selectedId: string; + onChange?: (value: string) => void; + }; + handoff?: { + onSelect: (selection: { + providerId: string; + model: string; + reasoningLevel: "medium"; + }) => void; + }; reasoning: { value: string }; serviceTier?: { value?: string }; }; @@ -173,9 +192,23 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => {
{composer?.submitTitle ?? "Submit"}
+
{composer?.submitLabel ?? ""}
+
{composer?.submitIcon ?? ""}
{suppressPluginComposerCustomizations ? "true" : "false"}
+
{activePromptMode?.mode}
+
+ {execution.provider.selectedId} +
+
+ {execution.providerRouting.environmentId} +
+
+ {typeahead.command?.suggestions + .map((command) => command.name) + .join(",")} +
{execution.model.active?.model}
{execution.reasoning.value}
@@ -276,9 +309,34 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { ) : null} - {execution.footerAction ? ( - + + + ) : null} + {execution.handoff ? ( + ) : null}
@@ -452,13 +510,19 @@ vi.mock("@/components/ui/app-toast", () => ({ })); vi.mock("@/hooks/useCommandSuggestions", () => ({ - useCommandSuggestions: () => ({ + useCommandSuggestions: ({ + providerId, + commandScope, + }: { + providerId: string; + commandScope: string; + }) => ({ hasMore: false, isError: false, isLoading: false, isLoadingMore: false, loadMore: vi.fn(), - suggestions: [], + suggestions: [{ name: `${providerId}:${commandScope}` }], trigger: null, }), })); @@ -476,38 +540,56 @@ vi.mock("@/hooks/usePromptMentions", () => ({ }), })); -vi.mock("@/hooks/useThreadCreationOptions", () => ({ - useThreadCreationOptions: (options: unknown) => { - 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/useThreadCreationOptions", async () => { + const { useState } = await import("react"); + return { + useThreadCreationOptions: (options: { + initialProviderId: string; + initialPermissionMode?: PermissionMode; + }) => { + mocks.useThreadCreationOptions(options); + const [selectedProviderId, setSelectedProviderId] = useState( + options.initialProviderId, + ); + const isClaude = selectedProviderId === "claude-code"; + return { + activeModel: null, + executionInputSources: {}, + executionOptionsRouting: { hostId: "host_1" }, + providers: [], + hasMultipleProviders: true, + isLoadingModels: false, + modelLoadError: null, + modelLoadFailed: false, + modelOptions: [], + moreModelOptions: [], + permissionMode: options.initialPermissionMode ?? "auto", + permissionModeOptions: [], + providerOptions: [ + { value: "codex", label: "Codex" }, + { value: "claude-code", label: "Claude Code" }, + ], + reasoningLevel: "medium", + reasoningOptions: [], + selectedModel: isClaude ? "claude-opus-5" : "gpt-5", + selectedProviderComposerActions: [], + selectedProviderDisplayName: isClaude ? "Claude Code" : "Codex", + selectedProviderId, + serviceTier: undefined, + serviceTierSupportByProvider: {}, + setPermissionMode: vi.fn(), + setReasoningLevel: vi.fn(), + setProviderModelReasoning: ({ providerId }: { providerId: string }) => + setSelectedProviderId(providerId), + setSelectedModel: vi.fn(), + setSelectedProviderId, + setServiceTier: vi.fn(), + supportsPermissionModeSelection: true, + supportsServiceTier: false, + }; + }, + }; +}); vi.mock("@/hooks/mutations/project-mutations", () => ({ useUploadPromptAttachment: () => ({ @@ -525,6 +607,10 @@ vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ isPending: false, mutate: mocks.clearThreadGoalMutate, }), + useCreateThread: () => ({ + isPending: false, + mutateAsync: mocks.createThreadMutateAsync, + }), useCreateThreadQueuedMessage: () => ({ isPending: false, mutateAsync: mocks.createQueuedMessageMutateAsync, @@ -743,7 +829,7 @@ function buildPromptAreaElement({ resolveMentionLink={() => null} sendMessage={{ isPending: false, - mutateAsync: vi.fn(), + mutateAsync: mocks.sendMessageMutateAsync, }} sentMessageEdit={sentMessageEdit} steerActiveThreadOnEnter={false} @@ -766,11 +852,24 @@ beforeEach(() => { mocks.defaultExecutionOptions = null; mocks.pluginComposerHost = null; mocks.promptDraft.text = ""; + mocks.promptDraft.mentions = []; + mocks.promptDraft.attachments = []; mocks.promptDraft.getCurrent.mockImplementation(() => ({ attachments: mocks.promptDraft.attachments, mentions: mocks.promptDraft.mentions, text: mocks.promptDraft.text, })); + mocks.promptDraft.setDraft.mockImplementation( + (draft: { + attachments: PromptDraftAttachment[]; + mentions: PromptTextMention[]; + text: string; + }) => { + mocks.promptDraft.attachments = draft.attachments; + mocks.promptDraft.mentions = draft.mentions; + mocks.promptDraft.text = draft.text; + }, + ); mocks.queuedMessages = []; mocks.updateQueuedMessageMutateAsync.mockResolvedValue(undefined); mocks.useThreadCreationOptions.mockClear(); @@ -1333,7 +1432,7 @@ describe("ThreadDetailPromptArea", () => { ).toBe("Second queued draft"); }); - it("shows the queued execution values as read-only while editing", () => { + it("keeps queued execution and commands source-locked during a bottom handoff", () => { mocks.defaultExecutionOptions = { model: "bottom-model", permissionMode: "auto", @@ -1351,6 +1450,7 @@ describe("ThreadDetailPromptArea", () => { ]; renderPromptArea(); + fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); fireEvent.click( screen.getByRole("button", { name: "Edit queued message 1" }), ); @@ -1358,6 +1458,29 @@ describe("ThreadDetailPromptArea", () => { screen.getByTestId("inline-queued-message-editor"), ); + expect(inlineEditor.getByTestId("selected-provider").textContent).toBe( + "codex", + ); + expect(inlineEditor.getByTestId("command-suggestions").textContent).toBe( + "codex:thread", + ); + const inlineHost = screen.getByTestId("inline-queued-message-editor"); + for (const name of ["Switch provider", "Complete handoff flow"]) { + expect(inlineEditor.queryByRole("button", { name })).toBeNull(); + expect(screen.getByRole("button", { name })).not.toBeNull(); + } + expect( + screen + .getAllByTestId("selected-provider") + .filter((element) => !inlineHost.contains(element)) + .map((element) => element.textContent), + ).toEqual(["claude-code"]); + expect( + screen + .getAllByTestId("command-suggestions") + .filter((element) => !inlineHost.contains(element)) + .map((element) => element.textContent), + ).toEqual(["claude-code:new-thread"]); expect(inlineEditor.getByTestId("selected-model").textContent).toBe( "queued-model", ); @@ -1376,9 +1499,6 @@ describe("ThreadDetailPromptArea", () => { expect(inlineEditor.getByTestId("permission-read-only").textContent).toBe( "true", ); - expect( - inlineEditor.queryByRole("button", { name: "Handoff to new thread" }), - ).toBeNull(); }); it("dismisses an inline edit when its thread changes or its live row disappears", async () => { @@ -1754,32 +1874,189 @@ describe("ThreadDetailPromptArea", () => { expect(screen.getByText("Model fallback")).toBeTruthy(); }); - it("opens root compose with a handoff seed for the current thread", () => { + it.each(["Switch provider", "Complete handoff flow"])( + "%s prepares a handoff and restores the draft on return", + (entryAction) => { + mocks.promptDraft.text = "Keep going"; + renderPromptArea(); + expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); + expect(screen.getByTestId("submit-label").textContent).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: entryAction })); + + expect(screen.getByTestId("submit-label").textContent).toBe("New thread"); + expect(screen.getByTestId("submit-icon").textContent).toBe( + "MessageSquarePlus", + ); + expect(screen.getByTestId("submit-title").textContent).toBe( + "Create new thread (Enter)", + ); + expect(screen.getByTestId("selected-model").textContent).toBe( + "claude-opus-5", + ); + expect(screen.getByTestId("submit-mode").textContent).toBe("ready:"); + expect(mocks.promptDraft.setDraft).toHaveBeenLastCalledWith( + expect.objectContaining({ + text: "Continue from @thread:thr_1\n\nKeep going", + }), + ); + + fireEvent.click( + screen.getByRole("button", { name: "Switch provider back" }), + ); + + expect(mocks.promptDraft.setDraft).toHaveBeenLastCalledWith({ + attachments: [], + mentions: [], + text: "Keep going", + }); + expect(screen.getByTestId("submit-label").textContent).toBe(""); + expect(screen.getByTestId("submit-icon").textContent).toBe(""); + expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); + }, + ); + + it("shows destination permissions instead of the source active Plan mode", async () => { + mocks.defaultExecutionOptions = { + model: "gpt-5", + permissionMode: "full", + reasoningLevel: "medium", + serviceTier: "default", + source: "client/turn/requested", + }; + mocks.createThreadMutateAsync.mockResolvedValue({ + id: "thr_new", + projectId: "proj_1", + }); + renderPromptArea({ activePromptMode: activePlan }); + expect(screen.getByTestId("active-permission-mode").textContent).toBe( + "plan", + ); + fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); + expect(screen.getByTestId("active-permission-mode").textContent).toBe(""); + expect(screen.getByTestId("selected-permission").textContent).toBe("full"); + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + await waitFor(() => + expect(mocks.createThreadMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: "claude-code", + permissionMode: "full", + }), + ), + ); + fireEvent.click( + screen.getByRole("button", { name: "Switch provider back" }), + ); + expect(screen.getByTestId("active-permission-mode").textContent).toBe( + "plan", + ); + }); + + it.each([false, true])( + "keeps the destination model after a source fallback (scheduled: %s)", + async (scheduled) => { + const thread = makeThread({ + providerId: "claude-code", + environmentId: "env_1", + }); + mocks.createThreadMutateAsync.mockResolvedValue({ + id: "thr_new", + projectId: "proj_1", + }); + const { rerender } = renderPromptArea({ thread }); + fireEvent.click( + screen.getByRole("button", { name: "Switch provider back" }), + ); + rerender( + buildPromptAreaElement({ + thread, + modelFallback: { + sourceSeq: 43, + detectedAt: 123, + originalModel: "claude-fable-5", + fallbackModel: "claude-opus-4-8", + reason: "refusal", + message: "Switched to Opus.", + }, + }), + ); + expect(screen.getByTestId("selected-model").textContent).toBe("gpt-5"); + expect(screen.getByTestId("preview-environment").textContent).toBe( + "env_1", + ); + if (scheduled) { + fireEvent.click( + screen.getByRole("button", { name: "Capture plugin host" }), + ); + await act(async () => { + await mocks.pluginComposerHost?.submit?.({ sendAt: 1234567890 }); + }); + } else { + fireEvent.click( + screen.getByRole("button", { name: "Submit composer" }), + ); + } + await waitFor(() => + expect(mocks.createThreadMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: "codex", + model: "gpt-5", + ...(scheduled ? { sendAt: 1234567890 } : {}), + }), + ), + ); + }, + ); + + it("creates a new thread from the draft as typed and navigates to it", async () => { + mocks.promptDraft.text = "Refactor the tests"; + mocks.createThreadMutateAsync.mockResolvedValue({ + id: "thr_new", + projectId: "proj_source", + }); + renderPromptArea({ thread: makeThread({ environmentId: "env_1", id: "thr_source", projectId: "proj_source", + runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null }, + status: "active", title: "Source thread", titleFallback: null, }), }); + fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); - fireEvent.click( - screen.getByRole("button", { name: "Handoff to new thread" }), - ); - - expect(mocks.navigate).toHaveBeenCalledWith("/projects/proj_source", { - state: { - focusPrompt: true, - reuseEnvironmentId: "env_1", - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: { - environmentId: "env_1", - projectId: "proj_source", - sourceThreadId: "thr_source", - sourceThreadTitle: "Source thread", - }, - }, - }); + await waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith( + "/projects/proj_source/threads/thr_new", + ), + ); + expect(mocks.createThreadMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + environment: { type: "reuse", environmentId: "env_1" }, + input: [ + expect.objectContaining({ + type: "text", + text: "Continue from @thread:thr_source\n\nRefactor the tests", + mentions: [ + expect.objectContaining({ + start: 14, + end: 32, + resource: expect.objectContaining({ threadId: "thr_source" }), + }), + ], + }), + ], + model: "claude-opus-5", + projectId: "proj_source", + providerId: "claude-code", + }), + ); + expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.promptDraft.clearIfCurrentMatches).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index f28e88f15b..8f1cb08f4e 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -55,6 +55,7 @@ import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflow import { ThreadBackgroundCommandsCard } from "@/components/promptbox/banner/ThreadBackgroundCommandsCard"; import { ThreadModelFallbackCard } from "@/components/promptbox/banner/ThreadModelFallbackCard"; import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame"; +import type { ModelReasoningPickerHandoffSelection } from "@/components/pickers/ModelReasoningPicker"; import type { WorkspaceChangedFileSelection, WorkspaceChangedFilesSection, @@ -82,6 +83,7 @@ import { type InlineQueuedMessageEditState, } from "@/components/thread/embedded-chat"; import { + useCreateThread, useCreateThreadQueuedMessage, useCancelThreadPlan, useClearThreadGoal, @@ -100,9 +102,14 @@ import { } from "@/lib/mutation-errors"; import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history"; import { usePromptHistoryEnabled } from "@/hooks/usePromptHistoryEnabled"; -import { getProjectComposeRoutePath } from "@/lib/route-paths"; +import { getThreadRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; -import { buildThreadHandoffLocationState } from "@bb/client-core"; +import { + buildThreadHandoffCreateRequest, + buildThreadHandoffFollowUpDraft, + stripThreadHandoffPrefix, + type ThreadHandoffCreateSeed, +} from "@bb/client-core"; import { emptyPromptDraftState, promptDraftToInput, @@ -128,6 +135,7 @@ import { } from "@bb/client-core"; const ignorePromptBannerFileClick = () => {}; +const ignoreToastedCreateThreadError = () => {}; export interface ThreadDetailSentMessageEdit { draft: PromptDraftState; @@ -457,6 +465,7 @@ export function ThreadDetailPromptArea({ const cancelThreadPlan = useCancelThreadPlan(); const clearThreadGoal = useClearThreadGoal(); const unarchiveThread = useUnarchiveThread(); + const createThread = useCreateThread(); const projectName = useProjectDisplayName( thread.projectId === PERSONAL_PROJECT_ID ? undefined : thread.projectId, ); @@ -604,6 +613,9 @@ export function ThreadDetailPromptArea({ const { executionOptionsRouting, selectedProviderId, + setSelectedProviderId, + setProviderModelReasoning, + providers, providerOptions, hasMultipleProviders, selectedProviderComposerActions, @@ -649,7 +661,9 @@ export function ThreadDetailPromptArea({ string | null >(null); const isFallbackModelActive = - modelFallback !== null && overriddenFallbackIdentity !== fallbackIdentity; + selectedProviderId === thread.providerId && + modelFallback !== null && + overriddenFallbackIdentity !== fallbackIdentity; const effectiveSelectedModel = isFallbackModelActive ? modelFallback.fallbackModel : (activeModel?.model ?? selectedModel); @@ -662,15 +676,130 @@ export function ThreadDetailPromptArea({ }, [fallbackIdentity, setSelectedModel], ); + const isHandoffProviderId = useCallback( + (providerId: string) => + providerId.length > 0 && + providerId !== thread.providerId && + providerOptions.some((option) => option.value === thread.providerId), + [providerOptions, thread.providerId], + ); + const isHandoffSelection = isHandoffProviderId(selectedProviderId); + const sourceThreadDisplayTitle = getThreadDisplayTitle({ + id: thread.id, + title: thread.title, + titleFallback: thread.titleFallback, + }); + const handoffSeed = useMemo( + () => ({ + environmentId: thread.environmentId, + projectId: thread.projectId, + sourceThreadId: thread.id, + sourceThreadTitle: sourceThreadDisplayTitle, + }), + [ + sourceThreadDisplayTitle, + thread.environmentId, + thread.id, + thread.projectId, + ], + ); + const syncHandoffDraft = useCallback( + (nextProviderId: string) => { + const currentDraft = promptDraft.getCurrent(); + if (isHandoffProviderId(nextProviderId)) { + const seededDraft = buildThreadHandoffFollowUpDraft( + handoffSeed, + currentDraft, + ); + if (seededDraft !== currentDraft) { + promptDraft.setDraft(seededDraft); + } + return; + } + const restoredDraft = stripThreadHandoffPrefix(handoffSeed, currentDraft); + if (restoredDraft !== null) { + promptDraft.setDraft(restoredDraft); + } + }, + [handoffSeed, isHandoffProviderId, promptDraft], + ); + const handleProviderChange = useCallback( + (providerId: string) => { + if (providerId === selectedProviderId) { + return; + } + if (fallbackIdentity !== null) { + setOverriddenFallbackIdentity(fallbackIdentity); + } + setSelectedProviderId(providerId); + syncHandoffDraft(providerId); + }, + [ + fallbackIdentity, + selectedProviderId, + setSelectedProviderId, + syncHandoffDraft, + ], + ); + const handleHandoffSelect = useCallback( + (selection: ModelReasoningPickerHandoffSelection) => { + if (fallbackIdentity !== null) { + setOverriddenFallbackIdentity(fallbackIdentity); + } + setProviderModelReasoning(selection); + syncHandoffDraft(selection.providerId); + }, + [fallbackIdentity, setProviderModelReasoning, syncHandoffDraft], + ); + useEffect(() => { + if (isHandoffSelection) { + return; + } + const restoredDraft = stripThreadHandoffPrefix( + handoffSeed, + promptDraft.getCurrent(), + ); + if (restoredDraft !== null) { + promptDraft.setDraft(restoredDraft); + } + }, [handoffSeed, isHandoffSelection, promptDraft]); + const hasSentMessageEdit = sentMessageEdit !== undefined; + useEffect(() => { + if (hasSentMessageEdit && isHandoffSelection) { + setSelectedProviderId(thread.providerId); + syncHandoffDraft(thread.providerId); + } + }, [ + hasSentMessageEdit, + isHandoffSelection, + setSelectedProviderId, + syncHandoffDraft, + thread.providerId, + ]); const { typeaheadConfig, promptActions } = useComposerTypeahead({ projectId: thread.projectId, mentionsProjectId: projectId, - providerId: thread.providerId, + providerId: selectedProviderId, + commandScope: isHandoffSelection ? "new-thread" : "thread", environmentId: thread.environmentId, currentThreadId: thread.id, selectedProviderComposerActions, resolveMentionLink, }); + const { + typeaheadConfig: inlineTypeaheadConfig, + promptActions: inlinePromptActions, + } = useComposerTypeahead({ + projectId: thread.projectId, + mentionsProjectId: projectId, + providerId: thread.providerId, + environmentId: thread.environmentId, + currentThreadId: thread.id, + selectedProviderComposerActions: providers.find( + (provider) => provider.id === thread.providerId, + )?.composerActions, + resolveMentionLink, + }); const runtimeDisplayStatus = thread.runtime.displayStatus; const shouldSteerWhenReady = runtimeDisplayStatus === "provisioning" || @@ -709,6 +838,7 @@ export function ThreadDetailPromptArea({ const isFollowUpSubmitting = sendMessage.isPending || createQueuedMessage.isPending || + createThread.isPending || isFollowUpShortcutSending; const handleStopThread = useCallback(() => { stopThread.mutate(thread.id); @@ -719,7 +849,16 @@ export function ThreadDetailPromptArea({ const handleClearGoal = useCallback(() => { clearThreadGoal.mutate(thread.id); }, [clearThreadGoal, thread.id]); - const submitMode: FollowUpSubmitMode = useMemo(() => { + const submitMode = useMemo(() => { + if (isHandoffSelection && !isStopRequested) { + if (effectiveSelectedModel.length > 0) { + return { kind: "ready" }; + } + return { + kind: "blocked", + reason: modelLoadFailed ? "unavailable" : "loading-execution-options", + }; + } return buildFollowUpSubmitMode({ hasPendingInteraction, isDefaultExecutionOptionsLoading, @@ -729,9 +868,12 @@ export function ThreadDetailPromptArea({ runtimeDisplayStatus, }); }, [ + effectiveSelectedModel, handleStopThread, hasPendingInteraction, isDefaultExecutionOptionsLoading, + isHandoffSelection, + modelLoadFailed, pendingInteractionsInitialLoading, isStopRequested, runtimeDisplayStatus, @@ -800,9 +942,69 @@ export function ThreadDetailPromptArea({ supportsServiceTier, ]); + const createHandoffThread = useCallback( + async (submittedDraft: PromptDraftState, sendAt?: number) => { + const request = buildThreadHandoffCreateRequest({ + execution: { + providerId: selectedProviderId, + model: effectiveSelectedModel, + reasoningLevel, + serviceTier, + supportsServiceTier, + permissionMode, + executionInputSources, + }, + draft: submittedDraft, + seed: handoffSeed, + ...(sendAt === undefined ? {} : { sendAt }), + }); + if (request === null) { + return false; + } + const clearedSubmittedDraft = + promptDraft.clearIfCurrentMatches(submittedDraft); + setBottomAttachmentError(null); + try { + const created = await createThread.mutateAsync(request); + navigate( + getThreadRoutePath({ + projectId: created.projectId, + threadId: created.id, + }), + ); + } catch (error) { + if (clearedSubmittedDraft) { + promptDraft.restoreIfEmpty(submittedDraft); + } + throw error; + } + return true; + }, + [ + createThread, + effectiveSelectedModel, + executionInputSources, + handoffSeed, + navigate, + permissionMode, + promptDraft, + reasoningLevel, + selectedProviderId, + serviceTier, + setBottomAttachmentError, + supportsServiceTier, + ], + ); + const handleSend = useCallback(async () => { const submittedDraft = currentPromptDraft; const submittedInput = currentPromptDraftInput; + if (isHandoffSelection) { + await createHandoffThread(submittedDraft).catch( + ignoreToastedCreateThreadError, + ); + return; + } const isQueuingMessage = shouldQueueFollowUpMessage(runtimeDisplayStatus); if ( submittedInput.length === 0 || @@ -845,11 +1047,13 @@ export function ThreadDetailPromptArea({ }); } }, [ + createHandoffThread, createQueuedMessage, currentPromptDraft, currentPromptDraftInput, followUpExecutionSelection, isDefaultExecutionOptionsLoading, + isHandoffSelection, promptDraft, sendMessage, setBottomAttachmentError, @@ -858,6 +1062,27 @@ export function ThreadDetailPromptArea({ ]); const submitScheduled = useCallback( async ({ sendAt }: { sendAt: number }) => { + if (isHandoffSelection) { + if (effectiveSelectedModel.length === 0) { + throw new Error("The selected model is still loading."); + } + let created = false; + try { + created = await createHandoffThread(promptDraft.getCurrent(), sendAt); + } catch (scheduleError) { + throw new Error( + getMutationErrorMessage({ + error: scheduleError, + fallbackMessage: "Failed to create thread", + lifecycleOperation: "create_thread", + }), + ); + } + if (!created) { + throw new Error("Type a message before scheduling it."); + } + return; + } if (isDefaultExecutionOptionsLoading) { throw new Error("This thread's model options are still loading."); } @@ -889,8 +1114,11 @@ export function ThreadDetailPromptArea({ } }, [ + createHandoffThread, + effectiveSelectedModel, followUpExecutionSelection, isDefaultExecutionOptionsLoading, + isHandoffSelection, promptDraft, sendMessage, setBottomAttachmentError, @@ -993,28 +1221,6 @@ export function ThreadDetailPromptArea({ const handleUnarchiveCurrentThread = useCallback(() => { unarchiveThread.mutate({ id: thread.id }); }, [thread.id, unarchiveThread]); - const sourceThreadDisplayTitle = getThreadDisplayTitle({ - id: thread.id, - title: thread.title, - titleFallback: thread.titleFallback, - }); - const handleHandoffToNewThread = useCallback(() => { - navigate(getProjectComposeRoutePath(thread.projectId), { - state: buildThreadHandoffLocationState({ - environmentId: thread.environmentId, - projectId: thread.projectId, - sourceThreadId: thread.id, - sourceThreadTitle: sourceThreadDisplayTitle, - }), - }); - }, [ - navigate, - sourceThreadDisplayTitle, - thread.environmentId, - thread.id, - thread.projectId, - ]); - const bottomAttachmentsConfig = useMemo( () => ({ items: currentPromptDraft.attachments, @@ -1057,6 +1263,13 @@ export function ThreadDetailPromptArea({ onChangeMessage: promptDraft.setTextAndMentions, onModifierSubmit: handleBottomComposerModifierSubmit, onSubmit: handleBottomComposerSubmit, + ...(isHandoffSelection + ? { + submitLabel: "New thread", + submitIcon: "MessageSquarePlus", + submitTitle: "Create new thread (Enter)", + } + : {}), compactPromptPlaceholder, promptPlaceholder, canModifierSubmit: canSubmitModifierShortcut, @@ -1071,6 +1284,7 @@ export function ThreadDetailPromptArea({ handleBottomComposerModifierSubmit, handleBottomComposerSubmit, isFollowUpSubmitting, + isHandoffSelection, promptHistoryDrafts, promptPlaceholder, promptDraft.setDraft, @@ -1124,10 +1338,14 @@ export function ThreadDetailPromptArea({ ]); const bottomExecutionConfig = useMemo( () => ({ - providerRouting: executionOptionsRouting, + providerRouting: + thread.environmentId === null + ? executionOptionsRouting + : { environmentId: thread.environmentId }, provider: { options: providerOptions, selectedId: selectedProviderId, + onChange: handleProviderChange, hasMultiple: hasMultipleProviders, }, model: { @@ -1154,17 +1372,18 @@ export function ThreadDetailPromptArea({ options: reasoningOptions, onChange: setReasoningLevel, }, - footerAction: { - label: "Handoff to new thread", - onClick: handleHandoffToNewThread, + handoff: { + sourceProviderId: thread.providerId, + onSelect: handleHandoffSelect, }, }), [ effectiveSelectedModel, executionOptionsRouting, hasMultipleProviders, - handleHandoffToNewThread, + handleHandoffSelect, handleModelChange, + handleProviderChange, isLoadingModels, modelLoadFailed, modelLoadError, @@ -1181,13 +1400,21 @@ export function ThreadDetailPromptArea({ setServiceTier, supportsServiceTier, serviceTierFastLabel, + thread.environmentId, + thread.providerId, ], ); const compactExecutionConfig = useMemo(() => { - const { footerAction: _footerAction, ...executionWithoutFooterAction } = - bottomExecutionConfig; - return executionWithoutFooterAction; - }, [bottomExecutionConfig]); + const { + handoff: _handoff, + provider: { onChange: _onProviderChange, ...lockedProvider }, + ...lockedExecution + } = bottomExecutionConfig; + return { + ...lockedExecution, + provider: { ...lockedProvider, selectedId: thread.providerId }, + }; + }, [bottomExecutionConfig, thread.providerId]); const inlineExecutionConfig = useMemo(() => { if (!inlineEditingQueuedMessage) return null; return { @@ -1373,13 +1600,13 @@ export function ThreadDetailPromptArea({ onSelectHistoryEntry: setActiveComposerDraft, permission: inlinePermissionConfig, pluginComposerHost: queuedMessagePluginComposerHost, - promptActions, + promptActions: inlinePromptActions, promptPlaceholder, submit: handleInlineComposerSubmit, submitMode: { kind: "ready" }, textEffects: queuedComposerTextEffects, threadRuntimeDisplayStatus: runtimeDisplayStatus, - typeahead: typeaheadConfig, + typeahead: inlineTypeaheadConfig, collapseResetKey: `queued-message:${queuedMessageId}`, }), }; @@ -1400,7 +1627,7 @@ export function ThreadDetailPromptArea({ isAttachingInlineFiles, isUpdateQueuedMessagePending, projectId, - promptActions, + inlinePromptActions, promptPlaceholder, queuedComposerTextEffects, queuedMessagePluginComposerHost, @@ -1408,7 +1635,7 @@ export function ThreadDetailPromptArea({ runtimeDisplayStatus, setActiveComposerDraft, thread.id, - typeaheadConfig, + inlineTypeaheadConfig, ]); usePublishPluginComposerHost( queuedMessageEditor @@ -1491,7 +1718,7 @@ export function ThreadDetailPromptArea({ sentMessageEdit.updateDraft(() => nextDraft), permission: bottomPermissionConfig, pluginComposerHost: sentMessagePluginComposerHost, - promptActions, + promptActions: inlinePromptActions, promptPlaceholder: "Edit message", submit: handleSentMessageEditSubmit, submitMode: sentMessageEditSubmitMode, @@ -1499,7 +1726,7 @@ export function ThreadDetailPromptArea({ suppressPluginComposerCustomizations: true, textEffects: sentMessageComposerTextEffects, threadRuntimeDisplayStatus: runtimeDisplayStatus, - typeahead: typeaheadConfig, + typeahead: inlineTypeaheadConfig, collapseResetKey: `sent-message:${operationId}`, })} , @@ -1514,7 +1741,7 @@ export function ThreadDetailPromptArea({ handleSentMessageEditSubmit, isAttachingSentMessageFiles, projectId, - promptActions, + inlinePromptActions, runtimeDisplayStatus, sentMessageAttachmentError, sentMessageComposerTextEffects, @@ -1522,7 +1749,7 @@ export function ThreadDetailPromptArea({ sentMessageEditSubmitMode, sentMessagePluginComposerHost, thread.id, - typeaheadConfig, + inlineTypeaheadConfig, ]); const childPendingInteractionBanners = useMemo( () => @@ -1718,7 +1945,7 @@ export function ThreadDetailPromptArea({ attachments={bottomAttachmentsConfig} stack={pendingInteractionNode ? pendingInteractionStack : promptStack} pendingInteraction={pendingInteractionNode} - activePromptMode={activePromptMode} + activePromptMode={isHandoffSelection ? null : activePromptMode} composer={shouldHideComposer ? null : bottomComposerConfig} pluginComposerHost={normalPluginComposerHost} pluginComposerScope={normalPluginComposerHost.scope} diff --git a/packages/client-core/src/prompt/thread-handoff-request.ts b/packages/client-core/src/prompt/thread-handoff-request.ts index ac00ac8f88..eb16d01983 100644 --- a/packages/client-core/src/prompt/thread-handoff-request.ts +++ b/packages/client-core/src/prompt/thread-handoff-request.ts @@ -1,8 +1,12 @@ -import type { PromptTextMention } from "@bb/domain"; -import type { PromptDraftState } from "./prompt-draft.js"; - -export const THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY = - "threadHandoffCreateSeed"; +import type { + PermissionMode, + PromptTextMention, + ReasoningLevel, + ServiceTier, +} from "@bb/domain"; +import type { ExistingThreadExecutionInputSources } from "@bb/server-contract"; +import type { AppCreateThreadRequest } from "../api-types.js"; +import { promptDraftToInput, type PromptDraftState } from "./prompt-draft.js"; export interface ThreadHandoffCreateSeed { environmentId: string | null; @@ -11,64 +15,6 @@ export interface ThreadHandoffCreateSeed { sourceThreadTitle: string; } -interface ThreadHandoffLocationState { - focusPrompt: true; - reuseEnvironmentId?: string; - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: ThreadHandoffCreateSeed; -} - -export function buildThreadHandoffLocationState( - seed: ThreadHandoffCreateSeed, -): ThreadHandoffLocationState { - return { - focusPrompt: true, - ...(seed.environmentId !== null - ? { reuseEnvironmentId: seed.environmentId } - : {}), - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: seed, - }; -} - -export function readThreadHandoffCreateSeedFromLocationState( - state: unknown, -): ThreadHandoffCreateSeed | null { - if (!state || typeof state !== "object") return null; - const candidate = (state as Record)[ - THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY - ]; - if (!candidate || typeof candidate !== "object") return null; - const value = candidate as Record; - if ( - typeof value.projectId !== "string" || - value.projectId.length === 0 || - typeof value.sourceThreadId !== "string" || - value.sourceThreadId.length === 0 || - typeof value.sourceThreadTitle !== "string" || - value.sourceThreadTitle.trim().length === 0 - ) { - return null; - } - if ( - value.environmentId !== undefined && - value.environmentId !== null && - typeof value.environmentId !== "string" - ) { - return null; - } - - const environmentId = - typeof value.environmentId === "string" && value.environmentId.length > 0 - ? value.environmentId - : null; - - return { - environmentId, - projectId: value.projectId, - sourceThreadId: value.sourceThreadId, - sourceThreadTitle: value.sourceThreadTitle.trim(), - }; -} - export function buildThreadHandoffPromptDraft( seed: ThreadHandoffCreateSeed, ): PromptDraftState { @@ -88,3 +34,129 @@ export function buildThreadHandoffPromptDraft( return { text, mentions: [mention], attachments: [] }; } + +const THREAD_HANDOFF_FOLLOW_UP_SEPARATOR = "\n\n"; + +export interface ThreadHandoffExecutionSelection { + providerId: string; + model: string; + reasoningLevel: ReasoningLevel; + serviceTier: ServiceTier | undefined; + supportsServiceTier: boolean; + permissionMode: PermissionMode; + executionInputSources: ExistingThreadExecutionInputSources; +} + +interface BuildThreadHandoffCreateRequestArgs { + draft: PromptDraftState; + execution: ThreadHandoffExecutionSelection; + seed: ThreadHandoffCreateSeed; + sendAt?: number; +} + +function threadHandoffPrefixLength( + seed: ThreadHandoffCreateSeed, + draft: PromptDraftState, +): number | null { + const handoff = buildThreadHandoffPromptDraft(seed); + const [handoffMention] = handoff.mentions; + const hasHandoffMention = + handoffMention !== undefined && + draft.mentions.some( + (mention) => + mention.start === handoffMention.start && + mention.end === handoffMention.end && + mention.resource.kind === "thread" && + mention.resource.threadId === seed.sourceThreadId, + ); + if (!hasHandoffMention || !draft.text.startsWith(handoff.text)) { + return null; + } + let prefixLength = handoff.text.length; + if (prefixLength < draft.text.length && draft.text[prefixLength] !== "\n") { + return null; + } + while (draft.text[prefixLength] === "\n") { + prefixLength += 1; + } + return prefixLength; +} + +export function buildThreadHandoffFollowUpDraft( + seed: ThreadHandoffCreateSeed, + draft: PromptDraftState, +): PromptDraftState { + if (threadHandoffPrefixLength(seed, draft) !== null) { + return draft; + } + const handoff = buildThreadHandoffPromptDraft(seed); + const offset = + handoff.text.length + THREAD_HANDOFF_FOLLOW_UP_SEPARATOR.length; + return { + text: `${handoff.text}${THREAD_HANDOFF_FOLLOW_UP_SEPARATOR}${draft.text}`, + mentions: [ + ...handoff.mentions, + ...draft.mentions.map((mention) => ({ + ...mention, + start: mention.start + offset, + end: mention.end + offset, + })), + ], + attachments: draft.attachments, + }; +} + +export function stripThreadHandoffPrefix( + seed: ThreadHandoffCreateSeed, + draft: PromptDraftState, +): PromptDraftState | null { + const prefixLength = threadHandoffPrefixLength(seed, draft); + if (prefixLength === null) { + return null; + } + return { + text: draft.text.slice(prefixLength), + mentions: draft.mentions + .filter((mention) => mention.start >= prefixLength) + .map((mention) => ({ + ...mention, + start: mention.start - prefixLength, + end: mention.end - prefixLength, + })), + attachments: draft.attachments, + }; +} + +export function buildThreadHandoffCreateRequest({ + draft, + execution, + seed, + sendAt, +}: BuildThreadHandoffCreateRequestArgs): AppCreateThreadRequest | null { + const input = promptDraftToInput(draft); + if (execution.model.length === 0 || input.length === 0) { + return null; + } + + return { + environment: + seed.environmentId === null + ? { type: "project-default" } + : { type: "reuse", environmentId: seed.environmentId }, + executionInputSources: { + providerId: "explicit", + ...execution.executionInputSources, + }, + input, + model: execution.model, + permissionMode: execution.permissionMode, + projectId: seed.projectId, + providerId: execution.providerId, + reasoningLevel: execution.reasoningLevel, + ...(execution.supportsServiceTier && execution.serviceTier + ? { serviceTier: execution.serviceTier } + : {}), + ...(sendAt === undefined ? {} : { sendAt }), + startedOnBehalfOf: null, + }; +} diff --git a/packages/client-core/test/thread-handoff-request.test.ts b/packages/client-core/test/thread-handoff-request.test.ts index 93860a8508..dfac5ed96d 100644 --- a/packages/client-core/test/thread-handoff-request.test.ts +++ b/packages/client-core/test/thread-handoff-request.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { - buildThreadHandoffLocationState, - buildThreadHandoffPromptDraft, - readThreadHandoffCreateSeedFromLocationState, - THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY, + buildThreadHandoffCreateRequest, + buildThreadHandoffFollowUpDraft, + stripThreadHandoffPrefix, type ThreadHandoffCreateSeed, + type ThreadHandoffExecutionSelection, } from "../src/prompt/thread-handoff-request.js"; const SEED: ThreadHandoffCreateSeed = { @@ -14,53 +14,209 @@ const SEED: ThreadHandoffCreateSeed = { sourceThreadTitle: "Source thread", }; -describe("thread handoff request", () => { - it("builds location state that focuses compose and reuses the source environment", () => { - expect(buildThreadHandoffLocationState(SEED)).toEqual({ - focusPrompt: true, - reuseEnvironmentId: "env_source", - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: SEED, +const EXECUTION: ThreadHandoffExecutionSelection = { + providerId: "claude-code", + model: "claude-opus-5", + reasoningLevel: "high", + serviceTier: "fast", + supportsServiceTier: true, + permissionMode: "auto", + executionInputSources: { model: "explicit", reasoningLevel: "explicit" }, +}; + +const SOURCE_MENTION = { + start: 14, + end: 32, + resource: { + kind: "thread" as const, + projectId: "proj_source", + threadId: "thr_source", + label: "Source thread", + }, +}; + +describe("buildThreadHandoffFollowUpDraft", () => { + it("keeps follow-up mentions anchored after the source thread mention", () => { + const draft = buildThreadHandoffFollowUpDraft(SEED, { + text: "Also see @thread:thr_other next", + mentions: [ + { + start: 9, + end: 26, + resource: { + kind: "thread", + projectId: "proj_source", + threadId: "thr_other", + label: "Other thread", + }, + }, + ], + attachments: [], }); + + expect(draft.text).toBe( + "Continue from @thread:thr_source\n\nAlso see @thread:thr_other next", + ); + expect(draft.mentions).toHaveLength(2); + expect(draft.mentions[0]).toEqual(SOURCE_MENTION); + expect( + draft.text.slice(draft.mentions[1]!.start, draft.mentions[1]!.end), + ).toBe("@thread:thr_other"); }); - it("reads a valid handoff seed from location state", () => { + it("leaves a draft alone when it already starts with the source reference", () => { + const draft = { + text: "Continue from @thread:thr_source\n\nKeep going", + mentions: [SOURCE_MENTION], + attachments: [], + }; + + expect(buildThreadHandoffFollowUpDraft(SEED, draft)).toBe(draft); + }); +}); + +describe("stripThreadHandoffPrefix", () => { + it("removes the inserted reference and re-anchors later mentions", () => { expect( - readThreadHandoffCreateSeedFromLocationState({ - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: { - ...SEED, - sourceThreadTitle: " Source thread ", + stripThreadHandoffPrefix(SEED, { + text: "Continue from @thread:thr_source\n\nSee @thread:thr_other", + mentions: [ + SOURCE_MENTION, + { + start: 38, + end: 55, + resource: { + kind: "thread", + projectId: "proj_source", + threadId: "thr_other", + label: "Other thread", + }, + }, + ], + attachments: [], + }), + ).toEqual({ + text: "See @thread:thr_other", + mentions: [ + { + start: 4, + end: 21, + resource: { + kind: "thread", + projectId: "proj_source", + threadId: "thr_other", + label: "Other thread", + }, }, + ], + attachments: [], + }); + expect( + stripThreadHandoffPrefix(SEED, { + text: "Continue from @thread:thr_source", + mentions: [SOURCE_MENTION], + attachments: [], }), - ).toEqual(SEED); + ).toEqual({ text: "", mentions: [], attachments: [] }); }); - it("builds a prompt draft with a rich mention to the source thread", () => { - const draft = buildThreadHandoffPromptDraft(SEED); + it("tolerates the editor collapsing the blank line after the reference", () => { + expect( + stripThreadHandoffPrefix(SEED, { + text: "Continue from @thread:thr_source\nKeep going\n", + mentions: [SOURCE_MENTION], + attachments: [], + }), + ).toEqual({ text: "Keep going\n", mentions: [], attachments: [] }); + expect( + buildThreadHandoffFollowUpDraft(SEED, { + text: "Continue from @thread:thr_source\nKeep going", + mentions: [SOURCE_MENTION], + attachments: [], + }).text, + ).toBe("Continue from @thread:thr_source\nKeep going"); + }); - expect(draft.text).toBe("Continue from @thread:thr_source"); - expect(draft.attachments).toEqual([]); - expect(draft.mentions).toEqual([ - { - start: "Continue from ".length, - end: "Continue from @thread:thr_source".length, - resource: { - kind: "thread", - projectId: "proj_source", - threadId: "thr_source", - label: "Source thread", - }, + it("returns null once the user has changed the reference", () => { + expect( + stripThreadHandoffPrefix(SEED, { + text: "Continue from @thread:thr_source please", + mentions: [SOURCE_MENTION], + attachments: [], + }), + ).toBeNull(); + expect( + stripThreadHandoffPrefix(SEED, { + text: "Continue from @thread:thr_source\n\nKeep going", + mentions: [], + attachments: [], + }), + ).toBeNull(); + }); +}); + +describe("buildThreadHandoffCreateRequest", () => { + it("creates a thread on the selected provider from the draft as typed", () => { + const request = buildThreadHandoffCreateRequest({ + draft: { + text: "Continue from @thread:thr_source\n\nRefactor the tests", + mentions: [SOURCE_MENTION], + attachments: [], + }, + execution: EXECUTION, + seed: SEED, + }); + + expect(request).toEqual({ + environment: { type: "reuse", environmentId: "env_source" }, + executionInputSources: { + providerId: "explicit", + model: "explicit", + reasoningLevel: "explicit", }, - ]); + input: [ + { + type: "text", + text: "Continue from @thread:thr_source\n\nRefactor the tests", + mentions: [SOURCE_MENTION], + }, + ], + model: "claude-opus-5", + permissionMode: "auto", + projectId: "proj_source", + providerId: "claude-code", + reasoningLevel: "high", + serviceTier: "fast", + startedOnBehalfOf: null, + }); + }); + + it("falls back to the project default environment and drops unsupported service tiers", () => { + const request = buildThreadHandoffCreateRequest({ + draft: { text: "Keep going", mentions: [], attachments: [] }, + execution: { ...EXECUTION, supportsServiceTier: false }, + seed: { ...SEED, environmentId: null }, + sendAt: 1_700_000_000_000, + }); + + expect(request?.environment).toEqual({ type: "project-default" }); + expect(request).not.toHaveProperty("serviceTier"); + expect(request?.sendAt).toBe(1_700_000_000_000); }); - it("returns null for unusable handoff state", () => { - expect(readThreadHandoffCreateSeedFromLocationState(null)).toBeNull(); + it("returns null without follow-up input or a resolved model", () => { expect( - readThreadHandoffCreateSeedFromLocationState({ - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: { - ...SEED, - sourceThreadId: "", - }, + buildThreadHandoffCreateRequest({ + draft: { text: " ", mentions: [], attachments: [] }, + execution: EXECUTION, + seed: SEED, + }), + ).toBeNull(); + expect( + buildThreadHandoffCreateRequest({ + draft: { text: "Keep going", mentions: [], attachments: [] }, + execution: { ...EXECUTION, model: "" }, + seed: SEED, }), ).toBeNull(); });