From 3e6b9383bf3ae14779391983b80987519e6c9007 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 10 Sep 2026 17:00:27 -0400 Subject: [PATCH 01/36] Create a new thread from the follow-up composer when another provider is selected The follow-up composer locked its model picker to the thread's provider, so handing work to a different model meant leaving the thread through the "Handoff to new thread" footer action and re-typing the prompt in root compose. The bottom follow-up composer now exposes the same provider tabs as the new-thread picker. Selecting a provider other than the thread's own provider shows a toast explaining that submitting will create a new thread, retitles the submit control to "Create new thread", and on submit creates a thread on the selected provider and model that reuses the source environment and starts with "Continue from @thread:" followed by the typed follow-up, then navigates to it. Same-provider model changes still send follow-ups to the current thread. Queued-message inline editors, sent-message edits, and side chats keep the provider-locked picker; beginning a sent-message edit reverts a pending cross-provider selection so that edit stays on the thread's provider. Co-Authored-By: Claude Fable 5.1 --- .../ThreadDetailPromptArea.test.tsx | 186 +++++++++++++--- .../thread-detail/ThreadDetailPromptArea.tsx | 210 ++++++++++++++++-- .../src/prompt/thread-handoff-request.ts | 87 +++++++- .../test/thread-handoff-request.test.ts | 119 ++++++++++ 4 files changed, 542 insertions(+), 60 deletions(-) diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 559a1cb002..f7ae92f8e1 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -46,6 +46,7 @@ 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(), @@ -66,10 +67,12 @@ const mocks = vi.hoisted(() => ({ }, queuedMessages: [] as ThreadQueuedMessage[] | undefined, reorderQueuedMessageMutateAsync: vi.fn(), + sendMessageMutateAsync: vi.fn(), sendQueuedMessageMutateAsync: vi.fn(), setQueuedMessageGroupBoundaryMutateAsync: vi.fn(), stopThreadMutate: vi.fn(), toastError: vi.fn(), + toastMessage: vi.fn(), unarchiveThreadMutate: vi.fn(), uploadPromptAttachmentMutateAsync: vi.fn(), updateQueuedMessageMutateAsync: vi.fn(), @@ -128,6 +131,9 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { model: { active?: { model: string } | null; }; + provider: { + onChange?: (value: string) => void; + }; reasoning: { value: string }; serviceTier?: { value?: string }; }; @@ -280,6 +286,17 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { {execution.footerAction.label} ) : null} +
+ {execution.provider.onChange ? "true" : "false"} +
+ {execution.provider.onChange ? ( + + ) : null} ), }; @@ -447,7 +464,7 @@ vi.mock("@/components/plugin/PluginPendingInteractionComposer", () => ({ })); vi.mock("@/components/ui/app-toast", () => ({ - appToast: { error: mocks.toastError }, + appToast: { error: mocks.toastError, message: mocks.toastMessage }, })); vi.mock("@/hooks/useCommandSuggestions", () => ({ @@ -475,38 +492,47 @@ 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: unknown) => { + mocks.useThreadCreationOptions(options); + const [selectedProviderId, setSelectedProviderId] = useState("codex"); + const isClaude = selectedProviderId === "claude-code"; + return { + activeModel: null, + executionInputSources: {}, + hasMultipleProviders: true, + isLoadingModels: false, + modelLoadError: null, + modelLoadFailed: false, + modelOptions: [], + moreModelOptions: [], + permissionMode: "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(), + setSelectedModel: vi.fn(), + setSelectedProviderId, + setServiceTier: vi.fn(), + supportsPermissionModeSelection: true, + supportsServiceTier: false, + }; + }, + }; +}); vi.mock("@/hooks/mutations/project-mutations", () => ({ useUploadPromptAttachment: () => ({ @@ -524,6 +550,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, @@ -739,7 +769,7 @@ function buildPromptAreaElement({ resolveMentionLink={() => null} sendMessage={{ isPending: false, - mutateAsync: vi.fn(), + mutateAsync: mocks.sendMessageMutateAsync, }} sentMessageEdit={sentMessageEdit} steerActiveThreadOnEnter={false} @@ -1774,4 +1804,92 @@ describe("ThreadDetailPromptArea", () => { }, }); }); + it("lets only the bottom composer switch providers", () => { + mocks.queuedMessages = [makeQueuedMessage()]; + + renderPromptArea(); + fireEvent.click( + screen.getByRole("button", { name: "Edit queued message 1" }), + ); + + const inlineEditorHost = screen.getByTestId("inline-queued-message-editor"); + expect( + within(inlineEditorHost).getByTestId("provider-switchable").textContent, + ).toBe("false"); + const bottomComposer = screen + .getAllByTestId("follow-up-prompt-box") + .find((element) => !inlineEditorHost.contains(element)); + expect(bottomComposer).toBeDefined(); + expect( + within(bottomComposer!).getByTestId("provider-switchable").textContent, + ).toBe("true"); + }); + + it("notifies that submitting will create a new thread after picking another provider", () => { + renderPromptArea(); + expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); + + fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); + + expect(mocks.toastMessage).toHaveBeenCalledTimes(1); + expect(mocks.toastMessage).toHaveBeenCalledWith( + "Submitting will create a new thread", + expect.objectContaining({ + description: expect.stringContaining("Claude Code"), + }), + ); + 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.toastError).not.toHaveBeenCalled(); + }); + + it("creates a new thread from the follow-up 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" }, + status: "active", + title: "Source thread", + titleFallback: null, + }), + }); + fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + + 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", + }), + ], + 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 3ea1490aa0..567cfdf5cc 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -79,6 +79,7 @@ import { type InlineQueuedMessageEditState, } from "@/components/thread/embedded-chat"; import { + useCreateThread, useCreateThreadQueuedMessage, useCancelThreadPlan, useClearThreadGoal, @@ -97,9 +98,17 @@ import { } from "@/lib/mutation-errors"; import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history"; import { usePromptHistoryEnabled } from "@/hooks/usePromptHistoryEnabled"; -import { getProjectComposeRoutePath } from "@/lib/route-paths"; +import { + getProjectComposeRoutePath, + getThreadRoutePath, +} from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; -import { buildThreadHandoffLocationState } from "@bb/client-core"; +import { appToast } from "@/components/ui/app-toast"; +import { + buildThreadHandoffCreateRequest, + buildThreadHandoffLocationState, + type ThreadHandoffCreateSeed, +} from "@bb/client-core"; import { emptyPromptDraftState, promptDraftToInput, @@ -125,6 +134,7 @@ import { } from "@bb/client-core"; const ignorePromptBannerFileClick = () => {}; +const ignoreToastedCreateThreadError = () => {}; export interface ThreadDetailSentMessageEdit { draft: PromptDraftState; @@ -450,6 +460,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, ); @@ -597,8 +608,10 @@ export function ThreadDetailPromptArea({ const { executionOptionsRouting, selectedProviderId, + setSelectedProviderId, providerOptions, hasMultipleProviders, + selectedProviderDisplayName, selectedProviderComposerActions, selectedModel, setSelectedModel, @@ -655,6 +668,63 @@ export function ThreadDetailPromptArea({ }, [fallbackIdentity, setSelectedModel], ); + const handleProviderChange = useCallback( + (providerId: string) => { + if (providerId === selectedProviderId) { + return; + } + if (fallbackIdentity !== null) { + setOverriddenFallbackIdentity(fallbackIdentity); + } + setSelectedProviderId(providerId); + }, + [fallbackIdentity, selectedProviderId, setSelectedProviderId], + ); + const isHandoffSelection = + selectedProviderId.length > 0 && + selectedProviderId !== thread.providerId && + providerOptions.some((option) => option.value === thread.providerId); + 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 selectedProviderDisplayNameRef = useLatestRef( + selectedProviderDisplayName, + ); + useEffect(() => { + if (!isHandoffSelection) { + return; + } + appToast.message("Submitting will create a new thread", { + description: `Your follow-up starts a new ${selectedProviderDisplayNameRef.current} thread that continues from this one.`, + }); + }, [isHandoffSelection, selectedProviderDisplayNameRef]); + const hasSentMessageEdit = sentMessageEdit !== undefined; + useEffect(() => { + if (hasSentMessageEdit && isHandoffSelection) { + setSelectedProviderId(thread.providerId); + } + }, [ + hasSentMessageEdit, + isHandoffSelection, + setSelectedProviderId, + thread.providerId, + ]); const { typeaheadConfig, promptActions } = useComposerTypeahead({ projectId: thread.projectId, mentionsProjectId: projectId, @@ -702,6 +772,7 @@ export function ThreadDetailPromptArea({ const isFollowUpSubmitting = sendMessage.isPending || createQueuedMessage.isPending || + createThread.isPending || isFollowUpShortcutSending; const handleStopThread = useCallback(() => { stopThread.mutate(thread.id); @@ -712,7 +783,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, @@ -722,9 +802,12 @@ export function ThreadDetailPromptArea({ runtimeDisplayStatus, }); }, [ + effectiveSelectedModel, handleStopThread, hasPendingInteraction, isDefaultExecutionOptionsLoading, + isHandoffSelection, + modelLoadFailed, pendingInteractionsInitialLoading, isStopRequested, runtimeDisplayStatus, @@ -793,9 +876,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, + }, + followUp: 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 || @@ -838,11 +981,13 @@ export function ThreadDetailPromptArea({ }); } }, [ + createHandoffThread, createQueuedMessage, currentPromptDraft, currentPromptDraftInput, followUpExecutionSelection, isDefaultExecutionOptionsLoading, + isHandoffSelection, promptDraft, sendMessage, setBottomAttachmentError, @@ -851,6 +996,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."); } @@ -882,8 +1048,11 @@ export function ThreadDetailPromptArea({ } }, [ + createHandoffThread, + effectiveSelectedModel, followUpExecutionSelection, isDefaultExecutionOptionsLoading, + isHandoffSelection, promptDraft, sendMessage, setBottomAttachmentError, @@ -986,27 +1155,11 @@ 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, - }), + state: buildThreadHandoffLocationState(handoffSeed), }); - }, [ - navigate, - sourceThreadDisplayTitle, - thread.environmentId, - thread.id, - thread.projectId, - ]); + }, [handoffSeed, navigate, thread.projectId]); const bottomAttachmentsConfig = useMemo( () => ({ @@ -1050,6 +1203,9 @@ export function ThreadDetailPromptArea({ onChangeMessage: promptDraft.setTextAndMentions, onModifierSubmit: handleBottomComposerModifierSubmit, onSubmit: handleBottomComposerSubmit, + ...(isHandoffSelection + ? { submitTitle: "Create new thread (Enter)" } + : {}), compactPromptPlaceholder, promptPlaceholder, canModifierSubmit: canSubmitModifierShortcut, @@ -1064,6 +1220,7 @@ export function ThreadDetailPromptArea({ handleBottomComposerModifierSubmit, handleBottomComposerSubmit, isFollowUpSubmitting, + isHandoffSelection, promptHistoryDrafts, promptPlaceholder, promptDraft.setDraft, @@ -1121,6 +1278,7 @@ export function ThreadDetailPromptArea({ provider: { options: providerOptions, selectedId: selectedProviderId, + onChange: handleProviderChange, hasMultiple: hasMultipleProviders, }, model: { @@ -1158,6 +1316,7 @@ export function ThreadDetailPromptArea({ hasMultipleProviders, handleHandoffToNewThread, handleModelChange, + handleProviderChange, isLoadingModels, modelLoadFailed, modelLoadError, @@ -1177,9 +1336,12 @@ export function ThreadDetailPromptArea({ ], ); const compactExecutionConfig = useMemo(() => { - const { footerAction: _footerAction, ...executionWithoutFooterAction } = - bottomExecutionConfig; - return executionWithoutFooterAction; + const { + footerAction: _footerAction, + provider: { onChange: _onProviderChange, ...lockedProvider }, + ...executionWithoutFooterAction + } = bottomExecutionConfig; + return { ...executionWithoutFooterAction, provider: lockedProvider }; }, [bottomExecutionConfig]); const inlineExecutionConfig = useMemo(() => { if (!inlineEditingQueuedMessage) return null; diff --git a/packages/client-core/src/prompt/thread-handoff-request.ts b/packages/client-core/src/prompt/thread-handoff-request.ts index ac00ac8f88..6ad08c8ff5 100644 --- a/packages/client-core/src/prompt/thread-handoff-request.ts +++ b/packages/client-core/src/prompt/thread-handoff-request.ts @@ -1,5 +1,12 @@ -import type { PromptTextMention } from "@bb/domain"; -import type { PromptDraftState } from "./prompt-draft.js"; +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 const THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY = "threadHandoffCreateSeed"; @@ -88,3 +95,79 @@ 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 { + execution: ThreadHandoffExecutionSelection; + followUp: PromptDraftState; + seed: ThreadHandoffCreateSeed; + sendAt?: number; +} + +export function buildThreadHandoffFollowUpDraft( + seed: ThreadHandoffCreateSeed, + followUp: PromptDraftState, +): PromptDraftState { + const handoff = buildThreadHandoffPromptDraft(seed); + const offset = + handoff.text.length + THREAD_HANDOFF_FOLLOW_UP_SEPARATOR.length; + return { + text: `${handoff.text}${THREAD_HANDOFF_FOLLOW_UP_SEPARATOR}${followUp.text}`, + mentions: [ + ...handoff.mentions, + ...followUp.mentions.map((mention) => ({ + ...mention, + start: mention.start + offset, + end: mention.end + offset, + })), + ], + attachments: followUp.attachments, + }; +} + +export function buildThreadHandoffCreateRequest({ + execution, + followUp, + seed, + sendAt, +}: BuildThreadHandoffCreateRequestArgs): AppCreateThreadRequest | null { + if ( + execution.model.length === 0 || + promptDraftToInput(followUp).length === 0 + ) { + return null; + } + + return { + environment: + seed.environmentId === null + ? { type: "project-default" } + : { type: "reuse", environmentId: seed.environmentId }, + executionInputSources: { + providerId: "explicit", + ...execution.executionInputSources, + }, + input: promptDraftToInput(buildThreadHandoffFollowUpDraft(seed, followUp)), + 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..65cdb7eb9e 100644 --- a/packages/client-core/test/thread-handoff-request.test.ts +++ b/packages/client-core/test/thread-handoff-request.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + buildThreadHandoffCreateRequest, + buildThreadHandoffFollowUpDraft, buildThreadHandoffLocationState, buildThreadHandoffPromptDraft, readThreadHandoffCreateSeedFromLocationState, THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY, type ThreadHandoffCreateSeed, + type ThreadHandoffExecutionSelection, } from "../src/prompt/thread-handoff-request.js"; const SEED: ThreadHandoffCreateSeed = { @@ -65,3 +68,119 @@ describe("thread handoff request", () => { ).toBeNull(); }); }); + +const EXECUTION: ThreadHandoffExecutionSelection = { + providerId: "claude-code", + model: "claude-opus-5", + reasoningLevel: "high", + serviceTier: "fast", + supportsServiceTier: true, + permissionMode: "auto", + executionInputSources: { model: "explicit", reasoningLevel: "explicit" }, +}; + +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.text.slice(draft.mentions[0]!.start, draft.mentions[0]!.end), + ).toBe("@thread:thr_source"); + expect( + draft.text.slice(draft.mentions[1]!.start, draft.mentions[1]!.end), + ).toBe("@thread:thr_other"); + }); +}); + +describe("buildThreadHandoffCreateRequest", () => { + it("creates a thread on the selected provider that continues the source thread", () => { + const request = buildThreadHandoffCreateRequest({ + execution: EXECUTION, + followUp: { text: "Refactor the tests", mentions: [], attachments: [] }, + 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: [ + { + start: 14, + end: 32, + resource: { + kind: "thread", + projectId: "proj_source", + threadId: "thr_source", + label: "Source thread", + }, + }, + ], + }, + ], + 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({ + execution: { ...EXECUTION, supportsServiceTier: false }, + followUp: { text: "Keep going", mentions: [], attachments: [] }, + 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 without follow-up input or a resolved model", () => { + expect( + buildThreadHandoffCreateRequest({ + execution: EXECUTION, + followUp: { text: " ", mentions: [], attachments: [] }, + seed: SEED, + }), + ).toBeNull(); + expect( + buildThreadHandoffCreateRequest({ + execution: { ...EXECUTION, model: "" }, + followUp: { text: "Keep going", mentions: [], attachments: [] }, + seed: SEED, + }), + ).toBeNull(); + }); +}); From b7d1a0a5f636d5aa35fefc75961f5d86ae4e88e6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 10 Sep 2026 17:12:45 -0400 Subject: [PATCH 02/36] Remove the handoff footer action now that the composer creates the thread Selecting another provider in the follow-up composer now creates and opens the new thread directly, so the "Handoff to new thread" footer action in the model picker duplicated that flow with a worse one: it left the thread and seeded root compose with a draft that dropped anything already typed. Remove the footer action and everything that existed only to serve it: the model picker's footer action slot and its menu action button, the ExecutionControls prop that forwarded it, root compose's handoff seed handling, and client-core's handoff location-state helpers. The ThreadHandoffCreateSeed type and the "Continue from" draft builder stay because the inline handoff still uses them. Also fix the CI failures from the previous commit: the provider-switch test queried a test id that the nested inline editor duplicated, the create-thread test fixture omitted a required runtime field, and the keystrokes test's mutation mock lacked useCreateThread. Co-Authored-By: Claude Fable 5.1 --- .../pickers/ModelReasoningPicker.tsx | 72 +------------------ .../plugin/PluginNewThreadComposer.test.tsx | 66 ----------------- .../promptbox/ExecutionControls.test.tsx | 35 --------- .../promptbox/ExecutionControls.tsx | 11 +-- apps/app/src/views/RootComposeView.test.ts | 14 ---- apps/app/src/views/RootComposeView.tsx | 24 +------ ...ThreadDetailPromptArea.keystrokes.test.tsx | 1 + .../ThreadDetailPromptArea.test.tsx | 56 ++------------- .../thread-detail/ThreadDetailPromptArea.tsx | 26 ++----- .../src/prompt/thread-handoff-request.ts | 61 ---------------- .../test/thread-handoff-request.test.ts | 34 --------- 11 files changed, 17 insertions(+), 383 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index e1d71987de..40461dd82d 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -20,7 +20,7 @@ import { } from "./model-brand-prefix"; import { fastServiceTierLabel } from "@/lib/reasoning-labels"; import { Button } from "@bb/shared-ui/button"; -import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { Icon } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; import { COARSE_POINTER_ICON_SIZE_CLASS, @@ -191,15 +191,6 @@ 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; } export function ModelReasoningPicker({ @@ -233,7 +224,6 @@ export function ModelReasoningPicker({ modal = true, align = "start", disabled, - footerAction, }: ModelReasoningPickerProps) { const isCompactViewport = useIsCompactViewport(); const [open, setOpen] = useState(defaultOpen); @@ -714,16 +704,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); @@ -1141,21 +1121,6 @@ export function ModelReasoningPicker({ ) : null} - - {footerAction ? ( - <> -
-
- -
- - ) : null}
@@ -1448,41 +1413,6 @@ function MenuRowButton({ ); } - -function MenuActionButton({ - label, - iconName, - disabled, - title, - onClick, -}: { - label: string; - iconName: IconName; - disabled?: boolean; - title?: string; - onClick: () => void; -}) { - const { hoverProps } = useMenuItemHover(); - const isCompactViewport = useIsCompactViewport(); - return ( - - ); -} interface ModelSearchInputProps { inputRef: React.RefObject; query: string; diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index da25f4699a..4e5a7ff231 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -39,7 +39,6 @@ import { import { encodeReuseValue } from "@/components/pickers/environment-picker-value"; import { useRootComposeReuseEnvironment } from "@/lib/root-compose-selection"; import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage"; -import { buildThreadHandoffLocationState } from "@bb/client-core"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { makeProjectWithThreadsResponse } from "@/test/fixtures/projects"; import { RootComposeView } from "@/views/RootComposeView"; @@ -1092,71 +1091,6 @@ describe("PluginNewThreadComposer seeding", () => { expect(mocks.promptHistoryQueryOptions.at(-1)?.enabled).toBe(true); }); - it("keeps an unrelated draft attachment out of a RootComposeView handoff", async () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - window.localStorage.setItem("bb.root-compose.project-id", "proj_1"); - getPromptDraftAccessor({ kind: "new-thread" }).setDraft({ - text: "unrelated draft", - mentions: [], - attachments: [ - { - type: "localFile", - name: "unrelated.txt", - path: ".bb/attachments/unrelated.txt", - mimeType: "text/plain", - sizeBytes: 5, - }, - ], - }); - const router = createMemoryRouter( - [{ path: "/", element: }], - { - initialEntries: [ - { - pathname: "/", - state: buildThreadHandoffLocationState({ - environmentId: "env-handoff", - projectId: "proj_1", - sourceThreadId: "thr_source", - sourceThreadTitle: "Source thread", - }), - }, - ], - }, - ); - render( - - - - - , - ); - - expect(mocks.promptBoxProps[0]?.modeConfig.environment.value).toBe( - "provider:personal-workspace", - ); - expect(mocks.promptBoxProps[0]?.value).toBe("unrelated draft"); - expect(mocks.promptBoxProps[0]?.attachments.items).toHaveLength(1); - await waitFor(() => { - expect(latestPromptBoxProps().value).toBe( - "Continue from @thread:thr_source", - ); - }); - await waitFor(() => { - expect(router.state.location.state).toBeNull(); - }); - expect( - mocks.promptBoxProps.some( - (props) => - props.value === "Continue from @thread:thr_source" && - props.attachments.items.length > 0, - ), - ).toBe(false); - expect(latestPromptBoxProps().attachments.items).toEqual([]); - }); - it("applies a replacing initial prompt from location state exactly once", async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, diff --git a/apps/app/src/components/promptbox/ExecutionControls.test.tsx b/apps/app/src/components/promptbox/ExecutionControls.test.tsx index 5fd5accad5..097cb9e6a8 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.test.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.test.tsx @@ -96,41 +96,6 @@ describe("ExecutionControls", () => { expect(trigger.textContent).not.toContain("Failed to load models"); }); - it("shows the picker footer action even when model controls are unavailable", () => { - const props = makeExecutionControlsProps(); - - renderExecutionControls({ - ...props, - provider: { - options: [], - hasMultiple: false, - }, - model: { - ...props.model, - selected: "", - options: [], - }, - reasoning: { - ...props.reasoning, - options: [], - }, - footerAction: { - label: "Handoff to new thread", - onClick: () => {}, - }, - }); - - fireEvent.click( - screen.getByRole("button", { - name: "Provider, model and reasoning", - }), - ); - - expect( - screen.getByRole("button", { name: "Handoff to new thread" }), - ).not.toBeNull(); - }); - it("maps disabled fast mode to the explicit default service tier", () => { const onServiceTierChange = vi.fn(); renderExecutionControls({ diff --git a/apps/app/src/components/promptbox/ExecutionControls.tsx b/apps/app/src/components/promptbox/ExecutionControls.tsx index 48b25c64df..3a65b471e6 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.tsx @@ -5,10 +5,7 @@ import type { SystemProvidersQuery, } from "@bb/server-contract"; import { formatModelLabel } from "@/hooks/useThreadCreationOptions"; -import { - ModelReasoningPicker, - type ModelReasoningPickerFooterAction, -} from "@/components/pickers/ModelReasoningPicker"; +import { ModelReasoningPicker } from "@/components/pickers/ModelReasoningPicker"; import { type PickerOption } from "@/components/pickers/OptionPicker"; import type { ModelPickerOption } from "@/components/pickers/model-picker-option"; import type { ProviderPickerOption } from "@/components/pickers/model-brand-prefix"; @@ -58,7 +55,6 @@ export interface ExecutionControlsProps { model: ExecutionModelConfig; serviceTier?: ExecutionServiceTierConfig; reasoning: ExecutionReasoningConfig; - footerAction?: ModelReasoningPickerFooterAction; disabled?: boolean; } @@ -68,7 +64,6 @@ export const ExecutionControls = memo(function ExecutionControls({ model, serviceTier, reasoning, - footerAction, disabled, }: ExecutionControlsProps) { const handleServiceTierChange = serviceTier?.onChange ?? (() => {}); @@ -85,8 +80,7 @@ export const ExecutionControls = memo(function ExecutionControls({ model.loadFailed || model.options.length > 0 || canSwitchProviders || - selectedProviderId.length > 0 || - footerAction !== undefined; + selectedProviderId.length > 0; return ( <> @@ -117,7 +111,6 @@ export const ExecutionControls = memo(function ExecutionControls({ fastModeLabel={serviceTier?.fastLabel} muted disabled={disabled} - footerAction={footerAction} /> ) : null} diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index 3de3f3bfa8..e030380933 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -21,7 +21,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, @@ -743,19 +742,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 0f343e0008..7408d74fde 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, @@ -399,8 +395,7 @@ export function hasSingleUseRootComposeTargetState(state: unknown): boolean { return ( readRootComposeSectionTargetFromLocationState(state) !== null || readReuseEnvironmentIdFromLocationState(state) !== null || - readForkThreadCreateSeedFromLocationState(state) !== null || - readThreadHandoffCreateSeedFromLocationState(state) !== null + readForkThreadCreateSeedFromLocationState(state) !== null ); } @@ -742,9 +737,6 @@ function RootComposeSurface({ const nextForkSeed = readForkThreadCreateSeedFromLocationState( location.state, ); - const nextHandoffSeed = readThreadHandoffCreateSeedFromLocationState( - location.state, - ); if (!hasSingleUseRootComposeTargetState(location.state)) return; if (shouldStartComposingFromLocationState(location.state)) { setStartedComposing(true); @@ -757,7 +749,7 @@ function RootComposeSurface({ if (reuseEnvironmentId !== null) { seedEnvironmentSelectionValue(encodeReuseValue(reuseEnvironmentId)); } - if (nextForkSeed !== null && nextHandoffSeed === null) { + if (nextForkSeed !== null) { setForkSeed(nextForkSeed); setRootComposeProjectId(nextForkSeed.projectId); setProviderModelReasoning(nextForkSeed); @@ -767,17 +759,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, @@ -789,7 +770,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..75edda43d6 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -225,6 +225,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 f7ae92f8e1..5f3cb541bc 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -27,7 +27,6 @@ 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 { BbHttpError } from "@/lib/sdk"; import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host"; import { setComposerTextEffect } from "@/lib/composer-text-effects"; @@ -124,10 +123,6 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { } | null; environmentSummary?: ReactNode; execution: { - footerAction?: { - label: string; - onClick: () => void; - }; model: { active?: { model: string } | null; }; @@ -281,11 +276,6 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { ) : null} - {execution.footerAction ? ( - - ) : null}
{execution.provider.onChange ? "true" : "false"}
@@ -1398,9 +1388,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 () => { @@ -1776,34 +1763,6 @@ describe("ThreadDetailPromptArea", () => { expect(screen.getByText("Model fallback")).toBeTruthy(); }); - it("opens root compose with a handoff seed for the current thread", () => { - renderPromptArea({ - thread: makeThread({ - environmentId: "env_1", - id: "thr_source", - projectId: "proj_source", - title: "Source thread", - titleFallback: null, - }), - }); - - 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", - }, - }, - }); - }); it("lets only the bottom composer switch providers", () => { mocks.queuedMessages = [makeQueuedMessage()]; @@ -1816,13 +1775,12 @@ describe("ThreadDetailPromptArea", () => { expect( within(inlineEditorHost).getByTestId("provider-switchable").textContent, ).toBe("false"); - const bottomComposer = screen - .getAllByTestId("follow-up-prompt-box") - .find((element) => !inlineEditorHost.contains(element)); - expect(bottomComposer).toBeDefined(); - expect( - within(bottomComposer!).getByTestId("provider-switchable").textContent, - ).toBe("true"); + const bottomSwitchable = screen + .getAllByTestId("provider-switchable") + .filter((element) => !inlineEditorHost.contains(element)); + expect(bottomSwitchable.map((element) => element.textContent)).toEqual([ + "true", + ]); }); it("notifies that submitting will create a new thread after picking another provider", () => { @@ -1860,7 +1818,7 @@ describe("ThreadDetailPromptArea", () => { environmentId: "env_1", id: "thr_source", projectId: "proj_source", - runtime: { displayStatus: "active" }, + runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null }, status: "active", title: "Source thread", titleFallback: null, diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 567cfdf5cc..5c60bc1edd 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -98,15 +98,11 @@ import { } from "@/lib/mutation-errors"; import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history"; import { usePromptHistoryEnabled } from "@/hooks/usePromptHistoryEnabled"; -import { - getProjectComposeRoutePath, - getThreadRoutePath, -} from "@/lib/route-paths"; +import { getThreadRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { appToast } from "@/components/ui/app-toast"; import { buildThreadHandoffCreateRequest, - buildThreadHandoffLocationState, type ThreadHandoffCreateSeed, } from "@bb/client-core"; import { @@ -1155,12 +1151,6 @@ export function ThreadDetailPromptArea({ const handleUnarchiveCurrentThread = useCallback(() => { unarchiveThread.mutate({ id: thread.id }); }, [thread.id, unarchiveThread]); - const handleHandoffToNewThread = useCallback(() => { - navigate(getProjectComposeRoutePath(thread.projectId), { - state: buildThreadHandoffLocationState(handoffSeed), - }); - }, [handoffSeed, navigate, thread.projectId]); - const bottomAttachmentsConfig = useMemo( () => ({ items: currentPromptDraft.attachments, @@ -1305,16 +1295,11 @@ export function ThreadDetailPromptArea({ options: reasoningOptions, onChange: setReasoningLevel, }, - footerAction: { - label: "Handoff to new thread", - onClick: handleHandoffToNewThread, - }, }), [ effectiveSelectedModel, executionOptionsRouting, hasMultipleProviders, - handleHandoffToNewThread, handleModelChange, handleProviderChange, isLoadingModels, @@ -1336,12 +1321,9 @@ export function ThreadDetailPromptArea({ ], ); const compactExecutionConfig = useMemo(() => { - const { - footerAction: _footerAction, - provider: { onChange: _onProviderChange, ...lockedProvider }, - ...executionWithoutFooterAction - } = bottomExecutionConfig; - return { ...executionWithoutFooterAction, provider: lockedProvider }; + const { onChange: _onProviderChange, ...lockedProvider } = + bottomExecutionConfig.provider; + return { ...bottomExecutionConfig, provider: lockedProvider }; }, [bottomExecutionConfig]); const inlineExecutionConfig = useMemo(() => { if (!inlineEditingQueuedMessage) return null; diff --git a/packages/client-core/src/prompt/thread-handoff-request.ts b/packages/client-core/src/prompt/thread-handoff-request.ts index 6ad08c8ff5..28fb9efbf7 100644 --- a/packages/client-core/src/prompt/thread-handoff-request.ts +++ b/packages/client-core/src/prompt/thread-handoff-request.ts @@ -8,9 +8,6 @@ import type { ExistingThreadExecutionInputSources } from "@bb/server-contract"; import type { AppCreateThreadRequest } from "../api-types.js"; import { promptDraftToInput, type PromptDraftState } from "./prompt-draft.js"; -export const THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY = - "threadHandoffCreateSeed"; - export interface ThreadHandoffCreateSeed { environmentId: string | null; projectId: string; @@ -18,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 { diff --git a/packages/client-core/test/thread-handoff-request.test.ts b/packages/client-core/test/thread-handoff-request.test.ts index 65cdb7eb9e..2588269b01 100644 --- a/packages/client-core/test/thread-handoff-request.test.ts +++ b/packages/client-core/test/thread-handoff-request.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildThreadHandoffCreateRequest, buildThreadHandoffFollowUpDraft, - buildThreadHandoffLocationState, buildThreadHandoffPromptDraft, - readThreadHandoffCreateSeedFromLocationState, - THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY, type ThreadHandoffCreateSeed, type ThreadHandoffExecutionSelection, } from "../src/prompt/thread-handoff-request.js"; @@ -18,25 +15,6 @@ const SEED: ThreadHandoffCreateSeed = { }; 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, - }); - }); - - it("reads a valid handoff seed from location state", () => { - expect( - readThreadHandoffCreateSeedFromLocationState({ - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: { - ...SEED, - sourceThreadTitle: " Source thread ", - }, - }), - ).toEqual(SEED); - }); - it("builds a prompt draft with a rich mention to the source thread", () => { const draft = buildThreadHandoffPromptDraft(SEED); @@ -55,18 +33,6 @@ describe("thread handoff request", () => { }, ]); }); - - it("returns null for unusable handoff state", () => { - expect(readThreadHandoffCreateSeedFromLocationState(null)).toBeNull(); - expect( - readThreadHandoffCreateSeedFromLocationState({ - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: { - ...SEED, - sourceThreadId: "", - }, - }), - ).toBeNull(); - }); }); const EXECUTION: ThreadHandoffExecutionSelection = { From b3d04fb1c03af9bc01b01e410f377d9b9474aa18 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 10 Sep 2026 21:22:39 -0400 Subject: [PATCH 03/36] Seed the handoff reference into the draft instead of prepending it on submit Submitting a cross-provider follow-up silently prepended "Continue from @thread:" to whatever the user typed, so the message that started the new thread was never the message they saw in the composer. Selecting another provider now inserts that reference into the draft itself, followed by a blank line, and focuses the composer so the user can edit or delete it before submitting. Switching back to the thread's provider removes the reference again, but only while it is still exactly as inserted. The create-thread request sends the draft as typed, and the toast now says the reference was added and can be edited. client-core gains stripThreadHandoffPrefix as the counterpart of buildThreadHandoffFollowUpDraft, which also leaves a draft alone when the reference is already present so re-entering handoff mode never duplicates it. Co-Authored-By: Claude Fable 5.1 --- .../ThreadDetailPromptArea.test.tsx | 94 ++++++++++++++++-- .../thread-detail/ThreadDetailPromptArea.tsx | 32 +++++- .../src/prompt/thread-handoff-request.ts | 75 +++++++++++--- .../test/thread-handoff-request.test.ts | 99 ++++++++++++++++++- 4 files changed, 272 insertions(+), 28 deletions(-) diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 5f3cb541bc..8a5b0fa1e5 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -2,6 +2,7 @@ import type { PendingInteraction, + PromptTextMention, ResolvedThreadExecutionOptions, ThreadQueuedMessage, ThreadTimelineActivePromptMode, @@ -55,7 +56,7 @@ const mocks = vi.hoisted(() => ({ attachments: [], clearIfCurrentMatches: vi.fn(), getCurrent: vi.fn(), - mentions: [], + mentions: [] as PromptTextMention[], removeAttachment: vi.fn(), restoreIfEmpty: vi.fn(), setDraft: vi.fn(), @@ -280,12 +281,20 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { {execution.provider.onChange ? "true" : "false"} {execution.provider.onChange ? ( - + <> + + + ) : null} ), @@ -778,6 +787,7 @@ beforeEach(() => { mocks.defaultExecutionOptions = null; mocks.pluginComposerHost = null; mocks.promptDraft.text = ""; + mocks.promptDraft.mentions = []; mocks.promptDraft.getCurrent.mockImplementation(() => ({ attachments: mocks.promptDraft.attachments, mentions: mocks.promptDraft.mentions, @@ -1803,11 +1813,69 @@ describe("ThreadDetailPromptArea", () => { "claude-opus-5", ); expect(screen.getByTestId("submit-mode").textContent).toBe("ready:"); + expect(mocks.promptDraft.setDraft).toHaveBeenCalledWith({ + attachments: [], + mentions: [ + { + start: 14, + end: 27, + resource: expect.objectContaining({ + kind: "thread", + threadId: "thr_1", + }), + }, + ], + text: "Continue from @thread:thr_1\n\n", + }); expect(mocks.toastError).not.toHaveBeenCalled(); }); - it("creates a new thread from the follow-up and navigates to it", async () => { - mocks.promptDraft.text = "Refactor the tests"; + it("restores the typed draft when switching back to the thread's provider", () => { + mocks.promptDraft.text = "Continue from @thread:thr_1\n\nKeep going"; + mocks.promptDraft.mentions = [ + { + start: 14, + end: 27, + resource: { + kind: "thread", + projectId: "proj_1", + threadId: "thr_1", + label: "Test thread", + }, + }, + ]; + + renderPromptArea(); + fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); + expect(mocks.promptDraft.setDraft).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "Switch provider back" }), + ); + + expect(mocks.promptDraft.setDraft).toHaveBeenCalledWith({ + attachments: [], + mentions: [], + text: "Keep going", + }); + expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); + }); + + it("creates a new thread from the draft as typed and navigates to it", async () => { + mocks.promptDraft.text = + "Continue from @thread:thr_source\n\nRefactor the tests"; + mocks.promptDraft.mentions = [ + { + start: 14, + end: 32, + resource: { + kind: "thread", + projectId: "proj_source", + threadId: "thr_source", + label: "Source thread", + }, + }, + ]; mocks.createThreadMutateAsync.mockResolvedValue({ id: "thr_new", projectId: "proj_source", @@ -1825,6 +1893,7 @@ describe("ThreadDetailPromptArea", () => { }), }); fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); + expect(mocks.promptDraft.setDraft).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); await waitFor(() => @@ -1839,6 +1908,13 @@ describe("ThreadDetailPromptArea", () => { 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", diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 5c60bc1edd..38dc56a6bb 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -103,6 +103,8 @@ import { getThreadDisplayTitle } from "@/lib/thread-title"; import { appToast } from "@/components/ui/app-toast"; import { buildThreadHandoffCreateRequest, + buildThreadHandoffFollowUpDraft, + stripThreadHandoffPrefix, type ThreadHandoffCreateSeed, } from "@bb/client-core"; import { @@ -702,14 +704,38 @@ export function ThreadDetailPromptArea({ const selectedProviderDisplayNameRef = useLatestRef( selectedProviderDisplayName, ); + const wasHandoffSelectionRef = useRef(false); useEffect(() => { + if (wasHandoffSelectionRef.current === isHandoffSelection) { + return; + } + wasHandoffSelectionRef.current = isHandoffSelection; + const currentDraft = promptDraft.getCurrent(); if (!isHandoffSelection) { + const restoredDraft = stripThreadHandoffPrefix(handoffSeed, currentDraft); + if (restoredDraft !== null) { + promptDraft.setDraft(restoredDraft); + } return; } + const seededDraft = buildThreadHandoffFollowUpDraft( + handoffSeed, + currentDraft, + ); + if (seededDraft !== currentDraft) { + promptDraft.setDraft(seededDraft); + focusBottomPluginComposer(); + } appToast.message("Submitting will create a new thread", { - description: `Your follow-up starts a new ${selectedProviderDisplayNameRef.current} thread that continues from this one.`, + description: `A reference to this thread was added to your message. Edit it, then submit to start a new ${selectedProviderDisplayNameRef.current} thread.`, }); - }, [isHandoffSelection, selectedProviderDisplayNameRef]); + }, [ + focusBottomPluginComposer, + handoffSeed, + isHandoffSelection, + promptDraft, + selectedProviderDisplayNameRef, + ]); const hasSentMessageEdit = sentMessageEdit !== undefined; useEffect(() => { if (hasSentMessageEdit && isHandoffSelection) { @@ -884,7 +910,7 @@ export function ThreadDetailPromptArea({ permissionMode, executionInputSources, }, - followUp: submittedDraft, + draft: submittedDraft, seed: handoffSeed, ...(sendAt === undefined ? {} : { sendAt }), }); diff --git a/packages/client-core/src/prompt/thread-handoff-request.ts b/packages/client-core/src/prompt/thread-handoff-request.ts index 28fb9efbf7..6205e70099 100644 --- a/packages/client-core/src/prompt/thread-handoff-request.ts +++ b/packages/client-core/src/prompt/thread-handoff-request.ts @@ -48,43 +48,96 @@ export interface ThreadHandoffExecutionSelection { } interface BuildThreadHandoffCreateRequestArgs { + draft: PromptDraftState; execution: ThreadHandoffExecutionSelection; - followUp: PromptDraftState; 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) { + return null; + } + if ( + draft.text.startsWith( + `${handoff.text}${THREAD_HANDOFF_FOLLOW_UP_SEPARATOR}`, + ) + ) { + return handoff.text.length + THREAD_HANDOFF_FOLLOW_UP_SEPARATOR.length; + } + if (draft.text === handoff.text) { + return handoff.text.length; + } + return null; +} + export function buildThreadHandoffFollowUpDraft( seed: ThreadHandoffCreateSeed, - followUp: PromptDraftState, + 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}${followUp.text}`, + text: `${handoff.text}${THREAD_HANDOFF_FOLLOW_UP_SEPARATOR}${draft.text}`, mentions: [ ...handoff.mentions, - ...followUp.mentions.map((mention) => ({ + ...draft.mentions.map((mention) => ({ ...mention, start: mention.start + offset, end: mention.end + offset, })), ], - attachments: followUp.attachments, + 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, - followUp, seed, sendAt, }: BuildThreadHandoffCreateRequestArgs): AppCreateThreadRequest | null { - if ( - execution.model.length === 0 || - promptDraftToInput(followUp).length === 0 - ) { + const input = promptDraftToInput(draft); + if (execution.model.length === 0 || input.length === 0) { return null; } @@ -97,7 +150,7 @@ export function buildThreadHandoffCreateRequest({ providerId: "explicit", ...execution.executionInputSources, }, - input: promptDraftToInput(buildThreadHandoffFollowUpDraft(seed, followUp)), + input, model: execution.model, permissionMode: execution.permissionMode, projectId: seed.projectId, diff --git a/packages/client-core/test/thread-handoff-request.test.ts b/packages/client-core/test/thread-handoff-request.test.ts index 2588269b01..ccb3ec0ca9 100644 --- a/packages/client-core/test/thread-handoff-request.test.ts +++ b/packages/client-core/test/thread-handoff-request.test.ts @@ -3,6 +3,7 @@ import { buildThreadHandoffCreateRequest, buildThreadHandoffFollowUpDraft, buildThreadHandoffPromptDraft, + stripThreadHandoffPrefix, type ThreadHandoffCreateSeed, type ThreadHandoffExecutionSelection, } from "../src/prompt/thread-handoff-request.js"; @@ -45,6 +46,17 @@ const EXECUTION: ThreadHandoffExecutionSelection = { 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, { @@ -75,13 +87,90 @@ describe("buildThreadHandoffFollowUpDraft", () => { draft.text.slice(draft.mentions[1]!.start, draft.mentions[1]!.end), ).toBe("@thread:thr_other"); }); + + 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( + 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({ text: "", mentions: [], attachments: [] }); + }); + + 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 that continues the source thread", () => { + 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, - followUp: { text: "Refactor the tests", mentions: [], attachments: [] }, seed: SEED, }); @@ -122,8 +211,8 @@ describe("buildThreadHandoffCreateRequest", () => { 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 }, - followUp: { text: "Keep going", mentions: [], attachments: [] }, seed: { ...SEED, environmentId: null }, sendAt: 1_700_000_000_000, }); @@ -136,15 +225,15 @@ describe("buildThreadHandoffCreateRequest", () => { it("returns null without follow-up input or a resolved model", () => { expect( buildThreadHandoffCreateRequest({ + draft: { text: " ", mentions: [], attachments: [] }, execution: EXECUTION, - followUp: { text: " ", mentions: [], attachments: [] }, seed: SEED, }), ).toBeNull(); expect( buildThreadHandoffCreateRequest({ + draft: { text: "Keep going", mentions: [], attachments: [] }, execution: { ...EXECUTION, model: "" }, - followUp: { text: "Keep going", mentions: [], attachments: [] }, seed: SEED, }), ).toBeNull(); From 5db1451da9117cacb0150bb83fadbb02f670f939 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 10 Sep 2026 21:27:09 -0400 Subject: [PATCH 04/36] Recognize the handoff reference after the editor collapses its blank line The composer editor stores the seeded "Continue from @thread:" line followed by a single newline once the user types below it, so the exact two-newline match never fired: switching back to the thread's provider left the reference in place and re-entering handoff mode inserted a second copy. Detect the reference by its intact mention and its own line instead, and treat any run of newlines after it as the separator when stripping it or deciding not to insert it again. Co-Authored-By: Claude Fable 5.1 --- .../src/prompt/thread-handoff-request.ts | 17 +++++++---------- .../test/thread-handoff-request.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/client-core/src/prompt/thread-handoff-request.ts b/packages/client-core/src/prompt/thread-handoff-request.ts index 6205e70099..eb16d01983 100644 --- a/packages/client-core/src/prompt/thread-handoff-request.ts +++ b/packages/client-core/src/prompt/thread-handoff-request.ts @@ -69,20 +69,17 @@ function threadHandoffPrefixLength( mention.resource.kind === "thread" && mention.resource.threadId === seed.sourceThreadId, ); - if (!hasHandoffMention) { + if (!hasHandoffMention || !draft.text.startsWith(handoff.text)) { return null; } - if ( - draft.text.startsWith( - `${handoff.text}${THREAD_HANDOFF_FOLLOW_UP_SEPARATOR}`, - ) - ) { - return handoff.text.length + THREAD_HANDOFF_FOLLOW_UP_SEPARATOR.length; + let prefixLength = handoff.text.length; + if (prefixLength < draft.text.length && draft.text[prefixLength] !== "\n") { + return null; } - if (draft.text === handoff.text) { - return handoff.text.length; + while (draft.text[prefixLength] === "\n") { + prefixLength += 1; } - return null; + return prefixLength; } export function buildThreadHandoffFollowUpDraft( diff --git a/packages/client-core/test/thread-handoff-request.test.ts b/packages/client-core/test/thread-handoff-request.test.ts index ccb3ec0ca9..3e70aa4423 100644 --- a/packages/client-core/test/thread-handoff-request.test.ts +++ b/packages/client-core/test/thread-handoff-request.test.ts @@ -144,6 +144,23 @@ describe("stripThreadHandoffPrefix", () => { ).toEqual({ text: "", mentions: [], attachments: [] }); }); + 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"); + }); + it("returns null once the user has changed the reference", () => { expect( stripThreadHandoffPrefix(SEED, { From b9b50fcc3a7f05747b7a0aceb43b7f14fc3198f0 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 10:34:04 -0400 Subject: [PATCH 05/36] Guide the handoff from the picker footer without leaving the thread Bring back the "Handoff to new thread" entry at the bottom of the follow-up composer's model picker, but keep it inside the thread: clicking it turns the picker into a two-step flow that lists the other providers, then that provider's models, with a header showing the step and the chosen provider. Picking a model commits provider, model, and reasoning atomically through setProviderModelReasoning, closes the picker, and hands the composer to the existing handoff mode, which seeds the source-thread reference and explains that submitting will create a new thread. Back returns to the provider step and the close control cancels without touching the thread's selection. The picker reuses its provider preview catalog, menu rows, and hover chrome for the flow, and only the bottom composer passes the handoff config; inline queued-message and sent-message editors keep the locked picker. Co-Authored-By: Claude Fable 5.1 --- .../pickers/ModelReasoningPicker.test.tsx | 44 ++ .../pickers/ModelReasoningPicker.tsx | 455 ++++++++++++++---- .../promptbox/ExecutionControls.test.tsx | 20 + .../promptbox/ExecutionControls.tsx | 11 +- .../ThreadDetailPromptArea.test.tsx | 54 +++ .../thread-detail/ThreadDetailPromptArea.tsx | 26 +- 6 files changed, 509 insertions(+), 101 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index 8ad40116c0..330ff0a858 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -24,6 +24,7 @@ import { import { buildModelNavRows, ModelReasoningPicker, + type ModelReasoningPickerHandoff, } from "./ModelReasoningPicker"; import type { PickerOption } from "./OptionPicker"; import type { ProviderPickerOption } from "./model-brand-prefix"; @@ -159,6 +160,7 @@ function renderPicker({ compact = false, splitPane = false, muted = false, + handoff, }: { onSelectedProviderChange?: ((value: string) => void) | null; onModelChange?: (value: string) => void; @@ -177,6 +179,7 @@ function renderPicker({ compact?: boolean; splitPane?: boolean; muted?: boolean; + handoff?: ModelReasoningPickerHandoff; } = {}) { const { queryClient, wrapper } = createQueryClientTestHarness(); queryClient.setQueryData( @@ -218,6 +221,7 @@ function renderPicker({ showFastModeToggle={false} muted={muted} modal={false} + handoff={handoff} /> @@ -666,6 +670,46 @@ describe("ModelReasoningPicker", () => { expect(onModelChange).toHaveBeenCalledWith("claude-opus-4-7"); }); + it("guides a handoff through provider and model without leaving the picker", async () => { + const onSelect = vi.fn(); + const { onSelectedProviderChange, onModelChange } = renderPicker({ + handoff: { sourceProviderId: "codex", onSelect }, + }); + const trigger = screen.getByRole("button", { + name: "Provider, model and reasoning", + }); + + fireEvent.click(trigger); + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); + + expect(screen.getByText("Choose a provider")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Codex" })).toBeNull(); + expect(screen.queryByTitle("Claude Code")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Claude Code" })); + + expect(screen.getByText("Choose a model")).not.toBeNull(); + expect(screen.getByText("Claude Code")).not.toBeNull(); + expect(await screen.findByText("Opus 4.7")).not.toBeNull(); + expect(onSelectedProviderChange).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Back to providers" })); + expect(screen.getByText("Choose a provider")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Claude Code" })); + 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(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + it("loads provider models on the compose-selected host", async () => { renderPicker({ providerRouting: { hostId: "host-remote" } }); diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 40461dd82d..aa2a28b66c 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -20,7 +20,7 @@ import { } from "./model-brand-prefix"; import { fastServiceTierLabel } from "@/lib/reasoning-labels"; import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; +import { Icon, type IconName } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; import { COARSE_POINTER_ICON_SIZE_CLASS, @@ -93,6 +93,19 @@ interface ResolvedProviderPreview { supportsServiceTier: boolean; } +export interface ModelReasoningPickerHandoffSelection { + providerId: string; + model: string; + reasoningLevel: ReasoningLevel; +} + +export interface ModelReasoningPickerHandoff { + sourceProviderId: string; + onSelect: (selection: ModelReasoningPickerHandoffSelection) => void; +} + +type HandoffStep = "provider" | "model"; + const FAILED_TO_LOAD_MODELS_LABEL = "Failed to load models"; const EMPTY_MODEL_OPTIONS: readonly ModelPickerOption[] = []; const preserveModelLabel = (displayName: string): string => displayName; @@ -191,6 +204,7 @@ interface ModelReasoningPickerProps { modal?: boolean; align?: "start" | "center" | "end"; disabled?: boolean; + handoff?: ModelReasoningPickerHandoff; } export function ModelReasoningPicker({ @@ -224,6 +238,7 @@ export function ModelReasoningPicker({ modal = true, align = "start", disabled, + handoff, }: ModelReasoningPickerProps) { const isCompactViewport = useIsCompactViewport(); const [open, setOpen] = useState(defaultOpen); @@ -248,9 +263,11 @@ export function ModelReasoningPicker({ const [moreModelsOpen, setMoreModelsOpen] = useState(false); const [trackedSelectedProviderId, setTrackedSelectedProviderId] = useState(selectedProviderId); + const [handoffStep, setHandoffStep] = useState(null); if (trackedSelectedProviderId !== selectedProviderId) { setTrackedSelectedProviderId(selectedProviderId); + setHandoffStep(null); setPreviewProviderId(null); setShowMoreModels(false); setMoreModelsOpen(false); @@ -428,6 +445,7 @@ export function ModelReasoningPicker({ const isShowingModelError = !activeModelIsLoading && !hasActiveModelOptions && activeModelLoadFailed; const showProviderTabs = + handoffStep === null && hasMultipleProviders && onSelectedProviderChange !== undefined && providerOptions.length > 1 && @@ -481,6 +499,7 @@ export function ModelReasoningPicker({ activeIndex >= 0 && activeIndex < navRows.length ? activeIndex : -1; const effectiveShowFastModeToggle = + handoffStep === null && hasActiveModelOptions && (serviceTierSupportByProvider ? (serviceTierSupportByProvider[activeProviderId] ?? false) @@ -492,6 +511,7 @@ export function ModelReasoningPicker({ const showSelectedFastMode = hasSelectedModel && fastModeEnabled && modelOptions.length > 0; const showReasoningSection = + handoffStep === null && !isShowingModelError && activeReasoningOptions.length > 0 && (isPreviewing @@ -499,6 +519,7 @@ export function ModelReasoningPicker({ : hasSelectedModel && !modelIsLoading && !selectedModelLoadFailed); const resetBrowseState = useCallback(() => { + setHandoffStep(null); setPreviewProviderId(null); setShowMoreModels(false); setMoreModelsOpen(false); @@ -521,13 +542,61 @@ export function ModelReasoningPicker({ const handleModelSelect = useCallback( (model: string) => { if (previewSelectionBlocked) return; + if (handoff !== undefined && handoffStep === "model") { + handoff.onSelect({ + providerId: activeProviderId, + model, + reasoningLevel: + (isPreviewing ? previewSelection?.reasoningLevel : undefined) ?? + reasoningValue, + }); + setOpen(false); + resetBrowseState(); + return; + } onModelChange(model); setMoreModelsOpen(false); setPreviewProviderId(null); }, - [onModelChange, previewSelectionBlocked], + [ + activeProviderId, + handoff, + handoffStep, + isPreviewing, + onModelChange, + previewSelection, + previewSelectionBlocked, + reasoningValue, + resetBrowseState, + ], ); + const startHandoffFlow = useCallback(() => { + setHandoffStep("provider"); + setPreviewProviderId(null); + setShowMoreModels(false); + setMoreModelsOpen(false); + setSearchQuery(""); + setActiveIndex(-1); + }, []); + const handleHandoffProviderSelect = useCallback( + (providerId: string) => { + setPreviewProviderId( + providerId === selectedProviderId ? null : providerId, + ); + setHandoffStep("model"); + setSearchQuery(""); + setActiveIndex(-1); + }, + [selectedProviderId], + ); + const handleHandoffBack = useCallback(() => { + setPreviewProviderId(null); + setSearchQuery(""); + setActiveIndex(-1); + setHandoffStep((current) => (current === "model" ? "provider" : null)); + }, []); + const handleProviderSelect = useCallback( (providerId: string) => { onSelectedProviderChange?.(providerId); @@ -871,6 +940,7 @@ export function ModelReasoningPicker({ } const showSearchInput = + handoffStep !== "provider" && hasActiveModelOptions && !activeModelIsLoading && !isShowingModelError && @@ -882,7 +952,7 @@ export function ModelReasoningPicker({ {trigger} + {handoffStep !== null ? ( + + ) : null} {showProviderTabs ? (
-
- {isShowingModelError ? null : ( - Model - )} - {activeModelIsLoading ? ( - - ) : hasActiveModelOptions ? ( - <> - {navRows.map((row, index) => { - const active = highlightedIndex === index; - const domId = optionDomId(index); - if (row.kind === "more-toggle") { + {handoff !== undefined && handoffStep === "provider" ? ( + provider.value !== handoff.sourceProviderId, + )} + onSelect={handleHandoffProviderSelect} + /> + ) : ( +
+ {isShowingModelError ? null : ( + Model + )} + {activeModelIsLoading ? ( + + ) : hasActiveModelOptions ? ( + <> + {navRows.map((row, index) => { + const active = highlightedIndex === index; + const domId = optionDomId(index); + if (row.kind === "more-toggle") { + return ( + + setShowMoreModels((current) => !current) + } + /> + ); + } + const option = row.option; return ( - - setShowMoreModels((current) => !current) + label={stripModelBrandPrefix( + option.label, + activeBrandPrefix, + )} + qualifier={option.routeProviderId} + selected={ + !isPreviewing && option.value === modelValue } + disabled={previewSelectionBlocked} + onClick={() => handleModelSelect(option.value)} /> ); - } - const option = row.option; - return ( - 0 ? ( + + ) : null} + {isSearching && navRows.length === 0 ? ( +
handleModelSelect(option.value)} + > + No models match your search +
+ ) : null} + + ) : ( +
+ {activeModelLoadErrorMatches && activeModelLoadError ? ( + - ); - })} - {!isCompactViewport && - !isSearching && - filteredMoreModelOptions.length > 0 ? ( - - ) : null} - {isSearching && navRows.length === 0 ? ( -
- No models match your search -
- ) : null} - - ) : ( -
- {activeModelLoadErrorMatches && activeModelLoadError ? ( - - ) : activeModelLoadFailed ? ( - activeModelFailureMessage - ) : ( - "No models available" - )} -
- )} -
+ ) : activeModelLoadFailed ? ( + activeModelFailureMessage + ) : ( + "No models available" + )} +
+ )} +
+ )} {showReasoningSection ? ( <> @@ -1121,6 +1209,19 @@ export function ModelReasoningPicker({
) : null} + + {handoff !== undefined && handoffStep === null ? ( + <> +
+
+ +
+ + ) : null}
@@ -1128,6 +1229,168 @@ export function ModelReasoningPicker({ ); } +function HandoffFlowHeader({ + step, + provider, + onBack, +}: { + step: HandoffStep; + provider: ProviderPickerOption | undefined; + onBack: () => void; +}) { + const isCompactViewport = useIsCompactViewport(); + const ProviderIcon = provider?.icon; + return ( +
+
+ + + Handoff to new thread + +
+
+ + {step === "provider" ? "Step 1 of 2" : "Step 2 of 2"} + + · + {step === "provider" ? ( + Choose a provider + ) : ( + <> + {ProviderIcon ? ( + + ) : null} + + {provider?.label ?? ""} + + · + Choose a model + + )} +
+
+ ); +} + +function HandoffProviderList({ + providers, + onSelect, +}: { + providers: readonly ProviderPickerOption[]; + onSelect: (providerId: string) => void; +}) { + const isCompactViewport = useIsCompactViewport(); + if (providers.length === 0) { + return ( +
+ No other providers are available. +
+ ); + } + return ( +
+ Provider + {providers.map((provider) => ( + onSelect(provider.value)} + /> + ))} +
+ ); +} + +function HandoffProviderRow({ + provider, + onClick, +}: { + provider: ProviderPickerOption; + onClick: () => void; +}) { + const { hoverProps } = useMenuItemHover(); + const isCompactViewport = useIsCompactViewport(); + const ProviderIcon = provider.icon; + return ( + + ); +} + +function MenuActionButton({ + label, + iconName, + onClick, +}: { + label: string; + iconName: IconName; + onClick: () => void; +}) { + const { hoverProps } = useMenuItemHover(); + const isCompactViewport = useIsCompactViewport(); + return ( + + ); +} + function MenuSectionLabel({ children, className, diff --git a/apps/app/src/components/promptbox/ExecutionControls.test.tsx b/apps/app/src/components/promptbox/ExecutionControls.test.tsx index 097cb9e6a8..9d75806951 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.test.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.test.tsx @@ -96,6 +96,26 @@ describe("ExecutionControls", () => { expect(trigger.textContent).not.toContain("Failed to load models"); }); + it("offers the in-picker handoff flow when configured", () => { + renderExecutionControls({ + ...makeExecutionControlsProps(vi.fn()), + handoff: { sourceProviderId: "codex", onSelect: vi.fn() }, + }); + + fireEvent.click( + screen.getByRole("button", { + name: "Provider, model and reasoning", + }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); + + expect(screen.getByText("Choose a provider")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Codex" })).toBeNull(); + expect(screen.getByRole("button", { name: "Claude Code" })).not.toBeNull(); + }); + it("maps disabled fast mode to the explicit default service tier", () => { const onServiceTierChange = vi.fn(); renderExecutionControls({ diff --git a/apps/app/src/components/promptbox/ExecutionControls.tsx b/apps/app/src/components/promptbox/ExecutionControls.tsx index 3a65b471e6..5a20873c97 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.tsx @@ -5,7 +5,10 @@ import type { SystemProvidersQuery, } from "@bb/server-contract"; import { formatModelLabel } from "@/hooks/useThreadCreationOptions"; -import { ModelReasoningPicker } from "@/components/pickers/ModelReasoningPicker"; +import { + ModelReasoningPicker, + type ModelReasoningPickerHandoff, +} from "@/components/pickers/ModelReasoningPicker"; import { type PickerOption } from "@/components/pickers/OptionPicker"; import type { ModelPickerOption } from "@/components/pickers/model-picker-option"; import type { ProviderPickerOption } from "@/components/pickers/model-brand-prefix"; @@ -55,6 +58,7 @@ export interface ExecutionControlsProps { model: ExecutionModelConfig; serviceTier?: ExecutionServiceTierConfig; reasoning: ExecutionReasoningConfig; + handoff?: ModelReasoningPickerHandoff; disabled?: boolean; } @@ -64,6 +68,7 @@ export const ExecutionControls = memo(function ExecutionControls({ model, serviceTier, reasoning, + handoff, disabled, }: ExecutionControlsProps) { const handleServiceTierChange = serviceTier?.onChange ?? (() => {}); @@ -80,7 +85,8 @@ export const ExecutionControls = memo(function ExecutionControls({ model.loadFailed || model.options.length > 0 || canSwitchProviders || - selectedProviderId.length > 0; + selectedProviderId.length > 0 || + handoff !== undefined; return ( <> @@ -111,6 +117,7 @@ export const ExecutionControls = memo(function ExecutionControls({ fastModeLabel={serviceTier?.fastLabel} muted disabled={disabled} + handoff={handoff} /> ) : null} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 8a5b0fa1e5..57d1401dcd 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -130,6 +130,13 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { provider: { onChange?: (value: string) => void; }; + handoff?: { + onSelect: (selection: { + providerId: string; + model: string; + reasoningLevel: "medium"; + }) => void; + }; reasoning: { value: string }; serviceTier?: { value?: string }; }; @@ -296,6 +303,23 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { ) : null} +
+ {execution.handoff ? "true" : "false"} +
+ {execution.handoff ? ( + + ) : null}
), }; @@ -523,6 +547,8 @@ vi.mock("@/hooks/useThreadCreationOptions", async () => { serviceTierSupportByProvider: {}, setPermissionMode: vi.fn(), setReasoningLevel: vi.fn(), + setProviderModelReasoning: ({ providerId }: { providerId: string }) => + setSelectedProviderId(providerId), setSelectedModel: vi.fn(), setSelectedProviderId, setServiceTier: vi.fn(), @@ -1791,6 +1817,34 @@ describe("ThreadDetailPromptArea", () => { expect(bottomSwitchable.map((element) => element.textContent)).toEqual([ "true", ]); + expect( + within(inlineEditorHost).getByTestId("handoff-flow").textContent, + ).toBe("false"); + expect( + screen + .getAllByTestId("handoff-flow") + .filter((element) => !inlineEditorHost.contains(element)) + .map((element) => element.textContent), + ).toEqual(["true"]); + }); + + it("enters handoff mode when the picker flow selects a provider and model", () => { + renderPromptArea(); + + fireEvent.click( + screen.getByRole("button", { name: "Complete handoff flow" }), + ); + + expect(mocks.toastMessage).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("submit-title").textContent).toBe( + "Create new thread (Enter)", + ); + expect(screen.getByTestId("selected-model").textContent).toBe( + "claude-opus-5", + ); + expect(mocks.promptDraft.setDraft).toHaveBeenCalledWith( + expect.objectContaining({ text: "Continue from @thread:thr_1\n\n" }), + ); }); it("notifies that submitting will create a new thread after picking another provider", () => { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 38dc56a6bb..edb4dc76d3 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -54,6 +54,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, @@ -607,6 +608,7 @@ export function ThreadDetailPromptArea({ executionOptionsRouting, selectedProviderId, setSelectedProviderId, + setProviderModelReasoning, providerOptions, hasMultipleProviders, selectedProviderDisplayName, @@ -678,6 +680,15 @@ export function ThreadDetailPromptArea({ }, [fallbackIdentity, selectedProviderId, setSelectedProviderId], ); + const handleHandoffSelect = useCallback( + (selection: ModelReasoningPickerHandoffSelection) => { + if (fallbackIdentity !== null) { + setOverriddenFallbackIdentity(fallbackIdentity); + } + setProviderModelReasoning(selection); + }, + [fallbackIdentity, setProviderModelReasoning], + ); const isHandoffSelection = selectedProviderId.length > 0 && selectedProviderId !== thread.providerId && @@ -1321,11 +1332,16 @@ export function ThreadDetailPromptArea({ options: reasoningOptions, onChange: setReasoningLevel, }, + handoff: { + sourceProviderId: thread.providerId, + onSelect: handleHandoffSelect, + }, }), [ effectiveSelectedModel, executionOptionsRouting, hasMultipleProviders, + handleHandoffSelect, handleModelChange, handleProviderChange, isLoadingModels, @@ -1344,12 +1360,16 @@ export function ThreadDetailPromptArea({ setServiceTier, supportsServiceTier, serviceTierFastLabel, + thread.providerId, ], ); const compactExecutionConfig = useMemo(() => { - const { onChange: _onProviderChange, ...lockedProvider } = - bottomExecutionConfig.provider; - return { ...bottomExecutionConfig, provider: lockedProvider }; + const { + handoff: _handoff, + provider: { onChange: _onProviderChange, ...lockedProvider }, + ...lockedExecution + } = bottomExecutionConfig; + return { ...lockedExecution, provider: lockedProvider }; }, [bottomExecutionConfig]); const inlineExecutionConfig = useMemo(() => { if (!inlineEditingQueuedMessage) return null; From 8db143fcf3e434c8d0160177c0859bc9f3a272c3 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 10:34:45 -0400 Subject: [PATCH 06/36] Keep the handoff step header readable inside the picker width The step-2 line crammed the provider name and "Choose a model" into one truncating row. Show the step on its own line and the chosen provider with its icon on the line below so neither is cut off. Co-Authored-By: Claude Fable 5.1 --- .../pickers/ModelReasoningPicker.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index aa2a28b66c..303dd37755 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -1274,21 +1274,16 @@ function HandoffFlowHeader({ {step === "provider" ? "Step 1 of 2" : "Step 2 of 2"} · - {step === "provider" ? ( - Choose a provider - ) : ( - <> - {ProviderIcon ? ( - - ) : null} - - {provider?.label ?? ""} - - · - Choose a model - - )} + + {step === "provider" ? "Choose a provider" : "Choose a model"} + + {step === "model" && provider ? ( +
+ {ProviderIcon ? : null} + {provider.label} +
+ ) : null} ); } From f82ad3f5458d8a8a8c3de05716d80e790849ad3f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:10:49 -0400 Subject: [PATCH 07/36] Make the handoff a picker mode with a composer cap instead of steps and a toast The stepped flow read like onboarding, and the toast overlapped the submit button while saying something the composer itself should show. The picker's "Handoff to new thread" entry now switches the picker into a handoff mode that keeps the familiar layout: a header replaces the top chrome, the provider tabs stay with the thread's own provider struck through and disabled, the first other provider is previewed, and search, models, and reasoning work as usual. Reasoning changes are held locally until a model is picked, which commits provider, model, and reasoning atomically, closes the picker, and enters handoff mode in the composer. The back control returns to the normal picker without touching the thread's selection. The composer now shows a cap above the prompt box, built on the same card chrome as the editing cap, naming the target provider and model and offering a cancel control that restores the thread's provider and removes the seeded reference. The submit button reads "New thread" in that state. The toast is gone. Draft seeding and stripping moved from an effect into the provider handlers, and a mount-time cleanup removes a stale reference after a remount. Co-Authored-By: Claude Fable 5.1 --- .../pickers/ModelReasoningPicker.test.tsx | 40 +- .../pickers/ModelReasoningPicker.tsx | 452 ++++++++---------- .../promptbox/ExecutionControls.test.tsx | 11 +- .../promptbox/FollowUpPromptBox.tsx | 8 + .../promptbox/PromptBoxInternal.tsx | 12 +- .../promptbox/banner/ThreadHandoffCap.tsx | 54 +++ .../ThreadDetailPromptArea.test.tsx | 31 +- .../thread-detail/ThreadDetailPromptArea.tsx | 146 ++++-- 8 files changed, 415 insertions(+), 339 deletions(-) create mode 100644 apps/app/src/components/promptbox/banner/ThreadHandoffCap.tsx diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index 330ff0a858..e147927cb6 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -670,11 +670,10 @@ describe("ModelReasoningPicker", () => { expect(onModelChange).toHaveBeenCalledWith("claude-opus-4-7"); }); - it("guides a handoff through provider and model without leaving the picker", async () => { + it("switches the picker into handoff mode without leaving the thread", async () => { const onSelect = vi.fn(); - const { onSelectedProviderChange, onModelChange } = renderPicker({ - handoff: { sourceProviderId: "codex", onSelect }, - }); + const { onSelectedProviderChange, onModelChange, onReasoningChange } = + renderPicker({ handoff: { sourceProviderId: "codex", onSelect } }); const trigger = screen.getByRole("button", { name: "Provider, model and reasoning", }); @@ -684,20 +683,30 @@ describe("ModelReasoningPicker", () => { screen.getByRole("button", { name: "Handoff to new thread" }), ); - expect(screen.getByText("Choose a provider")).not.toBeNull(); - expect(screen.queryByRole("button", { name: "Codex" })).toBeNull(); - expect(screen.queryByTitle("Claude Code")).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Claude Code" })); - - expect(screen.getByText("Choose a model")).not.toBeNull(); - expect(screen.getByText("Claude Code")).not.toBeNull(); + expect( + screen.getByText("Pick a provider and model for the new thread"), + ).not.toBeNull(); + const currentTab = screen.getByTitle("Codex (current thread)"); + expect(currentTab).toHaveProperty("disabled", true); expect(await screen.findByText("Opus 4.7")).not.toBeNull(); + expect(screen.queryByText("5.5")).toBeNull(); 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.queryByText("Pick a provider and model for the new thread"), + ).toBeNull(); + expect(screen.getByTitle("Codex")).toHaveProperty("disabled", false); + expect(screen.getAllByText("5.5")).toHaveLength(2); - fireEvent.click(screen.getByRole("button", { name: "Back to providers" })); - expect(screen.getByText("Choose a provider")).not.toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Claude Code" })); + fireEvent.click( + screen.getByRole("button", { name: "Handoff to new thread" }), + ); fireEvent.click(await screen.findByText("Opus 4.7")); expect(onSelect).toHaveBeenCalledExactlyOnceWith({ @@ -707,6 +716,7 @@ describe("ModelReasoningPicker", () => { }); expect(onSelectedProviderChange).not.toHaveBeenCalled(); expect(onModelChange).not.toHaveBeenCalled(); + expect(onReasoningChange).not.toHaveBeenCalled(); expect(trigger.getAttribute("aria-expanded")).toBe("false"); }); diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 303dd37755..20f6d651c4 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -104,8 +104,6 @@ export interface ModelReasoningPickerHandoff { onSelect: (selection: ModelReasoningPickerHandoffSelection) => void; } -type HandoffStep = "provider" | "model"; - const FAILED_TO_LOAD_MODELS_LABEL = "Failed to load models"; const EMPTY_MODEL_OPTIONS: readonly ModelPickerOption[] = []; const preserveModelLabel = (displayName: string): string => displayName; @@ -263,11 +261,14 @@ export function ModelReasoningPicker({ const [moreModelsOpen, setMoreModelsOpen] = useState(false); const [trackedSelectedProviderId, setTrackedSelectedProviderId] = useState(selectedProviderId); - const [handoffStep, setHandoffStep] = useState(null); + const [handoffMode, setHandoffMode] = useState(false); + const [handoffReasoningLevel, setHandoffReasoningLevel] = + useState(null); if (trackedSelectedProviderId !== selectedProviderId) { setTrackedSelectedProviderId(selectedProviderId); - setHandoffStep(null); + setHandoffMode(false); + setHandoffReasoningLevel(null); setPreviewProviderId(null); setShowMoreModels(false); setMoreModelsOpen(false); @@ -411,6 +412,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); @@ -445,9 +453,8 @@ export function ModelReasoningPicker({ const isShowingModelError = !activeModelIsLoading && !hasActiveModelOptions && activeModelLoadFailed; const showProviderTabs = - handoffStep === null && - hasMultipleProviders && - onSelectedProviderChange !== undefined && + (handoffMode || + (hasMultipleProviders && onSelectedProviderChange !== undefined)) && providerOptions.length > 1 && (!isShowingModelError || activeModelErrorIsProviderSpecific); @@ -499,7 +506,7 @@ export function ModelReasoningPicker({ activeIndex >= 0 && activeIndex < navRows.length ? activeIndex : -1; const effectiveShowFastModeToggle = - handoffStep === null && + !handoffMode && hasActiveModelOptions && (serviceTierSupportByProvider ? (serviceTierSupportByProvider[activeProviderId] ?? false) @@ -511,7 +518,6 @@ export function ModelReasoningPicker({ const showSelectedFastMode = hasSelectedModel && fastModeEnabled && modelOptions.length > 0; const showReasoningSection = - handoffStep === null && !isShowingModelError && activeReasoningOptions.length > 0 && (isPreviewing @@ -519,7 +525,8 @@ export function ModelReasoningPicker({ : hasSelectedModel && !modelIsLoading && !selectedModelLoadFailed); const resetBrowseState = useCallback(() => { - setHandoffStep(null); + setHandoffMode(false); + setHandoffReasoningLevel(null); setPreviewProviderId(null); setShowMoreModels(false); setMoreModelsOpen(false); @@ -542,11 +549,12 @@ export function ModelReasoningPicker({ const handleModelSelect = useCallback( (model: string) => { if (previewSelectionBlocked) return; - if (handoff !== undefined && handoffStep === "model") { + if (handoff !== undefined && handoffMode) { handoff.onSelect({ providerId: activeProviderId, model, reasoningLevel: + handoffReasoningLevel ?? (isPreviewing ? previewSelection?.reasoningLevel : undefined) ?? reasoningValue, }); @@ -561,7 +569,8 @@ export function ModelReasoningPicker({ [ activeProviderId, handoff, - handoffStep, + handoffMode, + handoffReasoningLevel, isPreviewing, onModelChange, previewSelection, @@ -571,44 +580,41 @@ export function ModelReasoningPicker({ ], ); - const startHandoffFlow = useCallback(() => { - setHandoffStep("provider"); - setPreviewProviderId(null); - setShowMoreModels(false); - setMoreModelsOpen(false); - setSearchQuery(""); - setActiveIndex(-1); - }, []); + const handoffProviderOptions = useMemo( + () => + handoff === undefined + ? providerOptions + : providerOptions.filter( + (provider) => provider.value !== handoff.sourceProviderId, + ), + [handoff, providerOptions], + ); const handleHandoffProviderSelect = useCallback( (providerId: string) => { setPreviewProviderId( providerId === selectedProviderId ? null : providerId, ); - setHandoffStep("model"); + setHandoffReasoningLevel(null); + setShowMoreModels(false); + setMoreModelsOpen(false); setSearchQuery(""); setActiveIndex(-1); }, [selectedProviderId], ); - const handleHandoffBack = useCallback(() => { + const startHandoffMode = useCallback(() => { + const firstProvider = handoffProviderOptions[0]; + setHandoffMode(true); + handleHandoffProviderSelect(firstProvider?.value ?? selectedProviderId); + }, [handleHandoffProviderSelect, handoffProviderOptions, selectedProviderId]); + const exitHandoffMode = useCallback(() => { + setHandoffMode(false); + setHandoffReasoningLevel(null); setPreviewProviderId(null); setSearchQuery(""); setActiveIndex(-1); - setHandoffStep((current) => (current === "model" ? "provider" : null)); }, []); - const handleProviderSelect = useCallback( - (providerId: string) => { - onSelectedProviderChange?.(providerId); - const nextPreviewProviderId = - open && providerId !== selectedProviderId ? providerId : null; - setPreviewProviderId(nextPreviewProviderId); - setSearchQuery(""); - setActiveIndex(-1); - }, - [onSelectedProviderChange, open, selectedProviderId], - ); - const paneContext = useOptionalPaneContext(); const isFocusedPane = paneContext?.isFocused ?? true; const isSplitPane = paneContext?.isSplitPane ?? false; @@ -686,6 +692,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 @@ -721,6 +737,10 @@ export function ModelReasoningPicker({ const handleReasoningSelect = useCallback( (level: ReasoningLevel) => { if (previewSelectionBlocked) return; + if (handoffMode) { + setHandoffReasoningLevel(level); + return; + } if (isPreviewing && previewSelection?.selectedModel) { onModelChange(previewSelection.selectedModel); } @@ -729,6 +749,7 @@ export function ModelReasoningPicker({ setMoreModelsOpen(false); }, [ + handoffMode, isPreviewing, previewSelection, onModelChange, @@ -759,11 +780,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(); @@ -940,7 +958,6 @@ export function ModelReasoningPicker({ } const showSearchInput = - handoffStep !== "provider" && hasActiveModelOptions && !activeModelIsLoading && !isShowingModelError && @@ -952,7 +969,7 @@ export function ModelReasoningPicker({ {trigger} - {handoffStep !== null ? ( - - ) : null} + {handoffMode ? : null} {showProviderTabs ? (
{ const TabIcon = provider.icon; const isActive = provider.value === activeProviderId; + const isHandoffSource = + handoffMode && + handoff !== undefined && + provider.value === handoff.sourceProviderId; return ( Handoff to new thread
-
- - {step === "provider" ? "Step 1 of 2" : "Step 2 of 2"} - - · - - {step === "provider" ? "Choose a provider" : "Choose a model"} - +
+ Pick a provider and model for the new thread
- {step === "model" && provider ? ( -
- {ProviderIcon ? : null} - {provider.label} -
- ) : null} -
- ); -} - -function HandoffProviderList({ - providers, - onSelect, -}: { - providers: readonly ProviderPickerOption[]; - onSelect: (providerId: string) => void; -}) { - const isCompactViewport = useIsCompactViewport(); - if (providers.length === 0) { - return ( -
- No other providers are available. -
- ); - } - return ( -
- Provider - {providers.map((provider) => ( - onSelect(provider.value)} - /> - ))}
); } -function HandoffProviderRow({ - provider, - onClick, -}: { - provider: ProviderPickerOption; - onClick: () => void; -}) { - const { hoverProps } = useMenuItemHover(); - const isCompactViewport = useIsCompactViewport(); - const ProviderIcon = provider.icon; - return ( - - ); -} - function MenuActionButton({ label, iconName, diff --git a/apps/app/src/components/promptbox/ExecutionControls.test.tsx b/apps/app/src/components/promptbox/ExecutionControls.test.tsx index 9d75806951..f4429f4aa6 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.test.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.test.tsx @@ -111,9 +111,14 @@ describe("ExecutionControls", () => { screen.getByRole("button", { name: "Handoff to new thread" }), ); - expect(screen.getByText("Choose a provider")).not.toBeNull(); - expect(screen.queryByRole("button", { name: "Codex" })).toBeNull(); - expect(screen.getByRole("button", { name: "Claude Code" })).not.toBeNull(); + expect( + screen.getByText("Pick a provider and model for the new thread"), + ).not.toBeNull(); + expect(screen.getByTitle("Codex (current thread)")).toHaveProperty( + "disabled", + true, + ); + expect(screen.getByTitle("Claude Code")).toHaveProperty("disabled", false); }); it("maps disabled fast mode to the explicit default service tier", () => { diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 23c3148857..6f7d5124e5 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -140,6 +140,7 @@ export interface FollowUpComposerProps { onModifierSubmit: () => void; onSubmit: () => void; onEscape?: () => void; + submitLabel?: string; submitTitle?: string; compactPromptPlaceholder: string; promptPlaceholder: string; @@ -159,6 +160,7 @@ export interface FollowUpPromptBoxProps { stack: ReactNode | null; activePromptMode?: ThreadTimelineActivePromptMode | null; composer: FollowUpComposerProps | null; + composerCap?: ReactNode; environmentSummary: ReactNode | null; contextWindowUsage: ContextWindowUsage | null; execution: ExecutionControlsProps; @@ -231,6 +233,7 @@ function FollowUpPromptBoxWithComposer({ stack, activePromptMode, composer, + composerCap = null, environmentSummary, contextWindowUsage, execution, @@ -728,6 +731,7 @@ function FollowUpPromptBoxWithComposer({ heightAnimationKey={isInteractionExpanded ? "expanded" : "compact"} mentionMenuPlacement="top" submission={{ + label: composer.submitLabel, onStop: onStopRuntime, isSubmitting: composer.isFollowUpSubmitting || isStopping, disabled: @@ -794,6 +798,7 @@ function FollowUpPromptBoxWithComposer({ defaultRenderer={ + {composerCap}
{composerElement}
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 45b4cfba2b..9117887c41 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -221,6 +221,7 @@ export interface PromptBoxSubmissionConfig { isSubmitting?: boolean; disabled?: boolean; disabledReason?: string; + label?: string; title?: string; isRunning?: boolean; onStop?: () => void; @@ -233,6 +234,7 @@ interface PromptSubmitButtonProps { disabledReason: string | undefined; isBusy: boolean; isCompact: boolean; + label: string | undefined; onClick: (event: ReactMouseEvent) => void; onPointerDown: (event: ReactPointerEvent) => void; onTouchSubmit: () => void; @@ -245,6 +247,7 @@ function PromptSubmitButton({ disabledReason, isBusy, isCompact, + label, onClick, onPointerDown, onTouchSubmit, @@ -312,12 +315,15 @@ function PromptSubmitButton({ } onClick(event); }} - className={className} + className={cn(className, label !== undefined && "w-auto gap-1.5 px-2.5")} > {isBusy ? ( ) : ( - + <> + {label !== undefined ? {label} : null} + + )} ); @@ -1187,6 +1193,7 @@ export function PromptBoxInternal({ isSubmitting = false, disabled: submitDisabled = false, disabledReason: submitDisabledReason, + label: submitLabel, title: submitTitle = "Submit (Enter)", isRunning = false, onStop, @@ -3366,6 +3373,7 @@ export function PromptBoxInternal({ ) : ( void; + providerIcon: ProviderPickerOption["icon"]; + providerLabel: string; +} + +export function ThreadHandoffCap({ + modelLabel, + onCancel, + providerIcon: ProviderIcon, + providerLabel, +}: ThreadHandoffCapProps) { + return ( + +
+ {ProviderIcon ? ( + + ) : ( + + )} + + New thread + + {" "} + with {providerLabel} · {modelLabel} when you submit + + + +
+
+ ); +} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 57d1401dcd..4656e85f26 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -98,6 +98,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { FollowUpPromptBox: ({ attachments, composer, + composerCap, environmentSummary, execution, executionReadOnly, @@ -119,9 +120,11 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { onChangeMessage: (message: string, mentions: []) => void; onEscape?: () => void; onSubmit: () => void; + submitLabel?: string; submitTitle?: string; submitMode: { kind: string; reason?: string }; } | null; + composerCap?: ReactNode; environmentSummary?: ReactNode; execution: { model: { @@ -181,6 +184,8 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => {
{composer?.submitTitle ?? "Submit"}
+
{composer?.submitLabel ?? ""}
+
{composerCap}
{suppressPluginComposerCustomizations ? "true" : "false"}
@@ -1835,7 +1840,9 @@ describe("ThreadDetailPromptArea", () => { screen.getByRole("button", { name: "Complete handoff flow" }), ); - expect(mocks.toastMessage).toHaveBeenCalledTimes(1); + expect( + screen.getByLabelText("Handoff to new thread").textContent, + ).toContain("Claude Code"); expect(screen.getByTestId("submit-title").textContent).toBe( "Create new thread (Enter)", ); @@ -1847,19 +1854,19 @@ describe("ThreadDetailPromptArea", () => { ); }); - it("notifies that submitting will create a new thread after picking another provider", () => { + it("caps the composer and relabels submit after picking another provider", () => { renderPromptArea(); expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); + expect(screen.queryByLabelText("Handoff to new thread")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); - expect(mocks.toastMessage).toHaveBeenCalledTimes(1); - expect(mocks.toastMessage).toHaveBeenCalledWith( - "Submitting will create a new thread", - expect.objectContaining({ - description: expect.stringContaining("Claude Code"), - }), - ); + expect(mocks.toastMessage).not.toHaveBeenCalled(); + const cap = screen.getByLabelText("Handoff to new thread"); + expect(cap.textContent).toContain("New thread"); + expect(cap.textContent).toContain("Claude Code"); + expect(cap.textContent).toContain("claude-opus-5"); + expect(screen.getByTestId("submit-label").textContent).toBe("New thread"); expect(screen.getByTestId("submit-title").textContent).toBe( "Create new thread (Enter)", ); @@ -1882,6 +1889,12 @@ describe("ThreadDetailPromptArea", () => { text: "Continue from @thread:thr_1\n\n", }); expect(mocks.toastError).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel handoff" })); + + expect(screen.queryByLabelText("Handoff to new thread")).toBeNull(); + expect(screen.getByTestId("submit-label").textContent).toBe(""); + expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); }); it("restores the typed draft when switching back to the thread's provider", () => { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index edb4dc76d3..4fc961fe67 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -53,6 +53,8 @@ import { ThreadPromptModeCard } from "@/components/promptbox/banner/ThreadPrompt import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflowCard"; import { ThreadBackgroundCommandsCard } from "@/components/promptbox/banner/ThreadBackgroundCommandsCard"; import { ThreadModelFallbackCard } from "@/components/promptbox/banner/ThreadModelFallbackCard"; +import { ThreadHandoffCap } from "@/components/promptbox/banner/ThreadHandoffCap"; +import { stripModelBrandPrefix } from "@/components/pickers/model-brand-prefix"; import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame"; import type { ModelReasoningPickerHandoffSelection } from "@/components/pickers/ModelReasoningPicker"; import type { @@ -101,7 +103,6 @@ import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history"; import { usePromptHistoryEnabled } from "@/hooks/usePromptHistoryEnabled"; import { getThreadRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; -import { appToast } from "@/components/ui/app-toast"; import { buildThreadHandoffCreateRequest, buildThreadHandoffFollowUpDraft, @@ -668,31 +669,14 @@ export function ThreadDetailPromptArea({ }, [fallbackIdentity, setSelectedModel], ); - const handleProviderChange = useCallback( - (providerId: string) => { - if (providerId === selectedProviderId) { - return; - } - if (fallbackIdentity !== null) { - setOverriddenFallbackIdentity(fallbackIdentity); - } - setSelectedProviderId(providerId); - }, - [fallbackIdentity, selectedProviderId, setSelectedProviderId], - ); - const handleHandoffSelect = useCallback( - (selection: ModelReasoningPickerHandoffSelection) => { - if (fallbackIdentity !== null) { - setOverriddenFallbackIdentity(fallbackIdentity); - } - setProviderModelReasoning(selection); - }, - [fallbackIdentity, setProviderModelReasoning], + const isHandoffProviderId = useCallback( + (providerId: string) => + providerId.length > 0 && + providerId !== thread.providerId && + providerOptions.some((option) => option.value === thread.providerId), + [providerOptions, thread.providerId], ); - const isHandoffSelection = - selectedProviderId.length > 0 && - selectedProviderId !== thread.providerId && - providerOptions.some((option) => option.value === thread.providerId); + const isHandoffSelection = isHandoffProviderId(selectedProviderId); const sourceThreadDisplayTitle = getThreadDisplayTitle({ id: thread.id, title: thread.title, @@ -712,50 +696,110 @@ export function ThreadDetailPromptArea({ thread.projectId, ], ); - const selectedProviderDisplayNameRef = useLatestRef( - selectedProviderDisplayName, - ); - const wasHandoffSelectionRef = useRef(false); - useEffect(() => { - if (wasHandoffSelectionRef.current === isHandoffSelection) { - return; - } - wasHandoffSelectionRef.current = isHandoffSelection; - const currentDraft = promptDraft.getCurrent(); - if (!isHandoffSelection) { + 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 seededDraft = buildThreadHandoffFollowUpDraft( + const restoredDraft = stripThreadHandoffPrefix( handoffSeed, - currentDraft, + promptDraft.getCurrent(), ); - if (seededDraft !== currentDraft) { - promptDraft.setDraft(seededDraft); - focusBottomPluginComposer(); + if (restoredDraft !== null) { + promptDraft.setDraft(restoredDraft); } - appToast.message("Submitting will create a new thread", { - description: `A reference to this thread was added to your message. Edit it, then submit to start a new ${selectedProviderDisplayNameRef.current} thread.`, - }); + }, [handoffSeed, isHandoffSelection, promptDraft]); + const handleCancelHandoff = useCallback(() => { + setSelectedProviderId(thread.providerId); + syncHandoffDraft(thread.providerId); + }, [setSelectedProviderId, syncHandoffDraft, thread.providerId]); + const handoffCap = useMemo(() => { + if (!isHandoffSelection) { + return null; + } + const providerOption = providerOptions.find( + (option) => option.value === selectedProviderId, + ); + const modelLabel = stripModelBrandPrefix( + modelOptions.find((option) => option.value === effectiveSelectedModel) + ?.label ?? effectiveSelectedModel, + providerOption?.brandPrefix, + ); + return ( + + ); }, [ - focusBottomPluginComposer, - handoffSeed, + effectiveSelectedModel, + handleCancelHandoff, isHandoffSelection, - promptDraft, - selectedProviderDisplayNameRef, + modelOptions, + providerOptions, + selectedProviderDisplayName, + selectedProviderId, ]); const hasSentMessageEdit = sentMessageEdit !== undefined; useEffect(() => { if (hasSentMessageEdit && isHandoffSelection) { setSelectedProviderId(thread.providerId); + syncHandoffDraft(thread.providerId); } }, [ hasSentMessageEdit, isHandoffSelection, setSelectedProviderId, + syncHandoffDraft, thread.providerId, ]); const { typeaheadConfig, promptActions } = useComposerTypeahead({ @@ -1231,7 +1275,10 @@ export function ThreadDetailPromptArea({ onModifierSubmit: handleBottomComposerModifierSubmit, onSubmit: handleBottomComposerSubmit, ...(isHandoffSelection - ? { submitTitle: "Create new thread (Enter)" } + ? { + submitLabel: "New thread", + submitTitle: "Create new thread (Enter)", + } : {}), compactPromptPlaceholder, promptPlaceholder, @@ -1893,6 +1940,7 @@ export function ThreadDetailPromptArea({ pendingInteraction={pendingInteractionNode} activePromptMode={activePromptMode} composer={shouldHideComposer ? null : bottomComposerConfig} + composerCap={shouldHideComposer ? null : handoffCap} pluginComposerHost={normalPluginComposerHost} pluginComposerScope={normalPluginComposerHost.scope} textEffects={promptTextEffects} From e57c282dc8194f2afb81b2f3c7004f3f0da10537 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:13:29 -0400 Subject: [PATCH 08/36] Let the labeled submit button grow on the compact composer The compact composer sizes its action buttons with a square utility that outranked the width override, so "New thread" was clipped on phones. Reset the square size explicitly and keep the height when a label is present. Co-Authored-By: Claude Fable 5.1 --- apps/app/src/components/promptbox/PromptBoxInternal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 9117887c41..294517d60b 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -315,7 +315,10 @@ function PromptSubmitButton({ } onClick(event); }} - className={cn(className, label !== undefined && "w-auto gap-1.5 px-2.5")} + className={cn( + className, + label !== undefined && "size-auto h-8 gap-1.5 px-2.5", + )} > {isBusy ? ( From 35abb69fcfd532001af2c3a4b66ca533e53067df Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:17:03 -0400 Subject: [PATCH 09/36] Exempt the labeled submit button from the compact composer's square sizing The compact-container rule pins the submit action to a two-rem square with no padding, which clipped the "New thread" label on phones. Mark the button when it carries a label and give that case auto width with inline padding. Co-Authored-By: Claude Fable 5.1 --- apps/app/src/app.css | 10 +++++++++- .../app/src/components/promptbox/PromptBoxInternal.tsx | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app.css b/apps/app/src/app.css index c4f97cb613..58bd39a73d 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -461,13 +461,21 @@ z-index: 1; } - [data-follow-up-composer] [data-promptbox-submit-action] { + [data-follow-up-composer] + [data-promptbox-submit-action]:not([data-promptbox-submit-labeled]) { width: 2rem; height: 2rem; margin-left: 0; padding: 0; } + [data-follow-up-composer] + [data-promptbox-submit-action][data-promptbox-submit-labeled] { + height: 2rem; + margin-left: 0; + padding-inline: 0.625rem; + } + [data-follow-up-composer] [data-follow-up-composer-footer] { min-height: 0; max-height: 0; diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 294517d60b..441918222f 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -260,6 +260,7 @@ function PromptSubmitButton({ const button = ( - - Handoff to new thread - - -
- Pick a provider and model for the new thread -
+ + + Handoff to new thread + ); } From 831586a2a348b8f7ab565ef49ac9f561a69e42da Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:32:30 -0400 Subject: [PATCH 12/36] Assert on the handoff back control instead of the removed hint Co-Authored-By: Claude Fable 5.1 --- apps/app/src/components/pickers/ModelReasoningPicker.test.tsx | 4 ++-- apps/app/src/components/promptbox/ExecutionControls.test.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index 13cabedc20..6abc6712a3 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -684,7 +684,7 @@ describe("ModelReasoningPicker", () => { ); expect( - screen.getByText("Pick a provider and model for the new thread"), + screen.getByRole("button", { name: "Back to model picker" }), ).not.toBeNull(); const currentTab = screen.getByTitle("Codex (current thread)"); expect(currentTab).toHaveProperty("disabled", true); @@ -699,7 +699,7 @@ describe("ModelReasoningPicker", () => { screen.getByRole("button", { name: "Back to model picker" }), ); expect( - screen.queryByText("Pick a provider and model for the new thread"), + screen.queryByRole("button", { name: "Back to model picker" }), ).toBeNull(); expect(screen.getByTitle("Codex")).toHaveProperty("disabled", false); expect(screen.getAllByText("5.5")).toHaveLength(2); diff --git a/apps/app/src/components/promptbox/ExecutionControls.test.tsx b/apps/app/src/components/promptbox/ExecutionControls.test.tsx index f4429f4aa6..cdc9831ddb 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.test.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.test.tsx @@ -112,7 +112,7 @@ describe("ExecutionControls", () => { ); expect( - screen.getByText("Pick a provider and model for the new thread"), + screen.getByRole("button", { name: "Back to model picker" }), ).not.toBeNull(); expect(screen.getByTitle("Codex (current thread)")).toHaveProperty( "disabled", From 25a6401e662eafeaa88bf07273a2ff0aa9688c23 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:36:16 -0400 Subject: [PATCH 13/36] Remove the handoff cap above the follow-up composer The labeled "New thread" submit button already says what will happen, and a cap above the composer would collide with the queued-message drawer. Co-Authored-By: Claude Fable 5.1 --- .../promptbox/FollowUpPromptBox.tsx | 6 --- .../promptbox/banner/ThreadHandoffCap.tsx | 54 ------------------- .../ThreadDetailPromptArea.test.tsx | 16 ++---- .../thread-detail/ThreadDetailPromptArea.tsx | 37 ------------- 4 files changed, 5 insertions(+), 108 deletions(-) delete mode 100644 apps/app/src/components/promptbox/banner/ThreadHandoffCap.tsx diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 6f7d5124e5..0d9a1b238b 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -160,7 +160,6 @@ export interface FollowUpPromptBoxProps { stack: ReactNode | null; activePromptMode?: ThreadTimelineActivePromptMode | null; composer: FollowUpComposerProps | null; - composerCap?: ReactNode; environmentSummary: ReactNode | null; contextWindowUsage: ContextWindowUsage | null; execution: ExecutionControlsProps; @@ -233,7 +232,6 @@ function FollowUpPromptBoxWithComposer({ stack, activePromptMode, composer, - composerCap = null, environmentSummary, contextWindowUsage, execution, @@ -798,7 +796,6 @@ function FollowUpPromptBoxWithComposer({ defaultRenderer={ - {composerCap}
{composerElement}
diff --git a/apps/app/src/components/promptbox/banner/ThreadHandoffCap.tsx b/apps/app/src/components/promptbox/banner/ThreadHandoffCap.tsx deleted file mode 100644 index e0fa462774..0000000000 --- a/apps/app/src/components/promptbox/banner/ThreadHandoffCap.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; -import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; -import type { ProviderPickerOption } from "@/components/pickers/model-brand-prefix"; - -interface ThreadHandoffCapProps { - modelLabel: string; - onCancel: () => void; - providerIcon: ProviderPickerOption["icon"]; - providerLabel: string; -} - -export function ThreadHandoffCap({ - modelLabel, - onCancel, - providerIcon: ProviderIcon, - providerLabel, -}: ThreadHandoffCapProps) { - return ( - -
- {ProviderIcon ? ( - - ) : ( - - )} - - New thread - - {" "} - with {providerLabel} · {modelLabel} when you submit - - - -
-
- ); -} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 46f05a455b..853579b7b1 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -99,7 +99,6 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { FollowUpPromptBox: ({ attachments, composer, - composerCap, environmentSummary, execution, executionReadOnly, @@ -125,7 +124,6 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { submitTitle?: string; submitMode: { kind: string; reason?: string }; } | null; - composerCap?: ReactNode; environmentSummary?: ReactNode; execution: { model: { @@ -186,7 +184,6 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { {composer?.submitTitle ?? "Submit"}
{composer?.submitLabel ?? ""}
-
{composerCap}
{suppressPluginComposerCustomizations ? "true" : "false"}
@@ -1867,18 +1864,14 @@ describe("ThreadDetailPromptArea", () => { ); }); - it("caps the composer and relabels submit after picking another provider", () => { + it("relabels submit after picking another provider", () => { renderPromptArea(); expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); - expect(screen.queryByLabelText("Handoff to new thread")).toBeNull(); + expect(screen.getByTestId("submit-label").textContent).toBe(""); fireEvent.click(screen.getByRole("button", { name: "Switch provider" })); expect(mocks.toastMessage).not.toHaveBeenCalled(); - const cap = screen.getByLabelText("Handoff to new thread"); - expect(cap.textContent).toContain("New thread"); - expect(cap.textContent).toContain("Claude Code"); - expect(cap.textContent).toContain("claude-opus-5"); expect(screen.getByTestId("submit-label").textContent).toBe("New thread"); expect(screen.getByTestId("submit-title").textContent).toBe( "Create new thread (Enter)", @@ -1903,9 +1896,10 @@ describe("ThreadDetailPromptArea", () => { }); expect(mocks.toastError).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Cancel handoff" })); + fireEvent.click( + screen.getByRole("button", { name: "Switch provider back" }), + ); - expect(screen.queryByLabelText("Handoff to new thread")).toBeNull(); expect(screen.getByTestId("submit-label").textContent).toBe(""); expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); }); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 4fc961fe67..47a1327191 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -53,8 +53,6 @@ import { ThreadPromptModeCard } from "@/components/promptbox/banner/ThreadPrompt import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflowCard"; import { ThreadBackgroundCommandsCard } from "@/components/promptbox/banner/ThreadBackgroundCommandsCard"; import { ThreadModelFallbackCard } from "@/components/promptbox/banner/ThreadModelFallbackCard"; -import { ThreadHandoffCap } from "@/components/promptbox/banner/ThreadHandoffCap"; -import { stripModelBrandPrefix } from "@/components/pickers/model-brand-prefix"; import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame"; import type { ModelReasoningPickerHandoffSelection } from "@/components/pickers/ModelReasoningPicker"; import type { @@ -612,7 +610,6 @@ export function ThreadDetailPromptArea({ setProviderModelReasoning, providerOptions, hasMultipleProviders, - selectedProviderDisplayName, selectedProviderComposerActions, selectedModel, setSelectedModel, @@ -756,39 +753,6 @@ export function ThreadDetailPromptArea({ promptDraft.setDraft(restoredDraft); } }, [handoffSeed, isHandoffSelection, promptDraft]); - const handleCancelHandoff = useCallback(() => { - setSelectedProviderId(thread.providerId); - syncHandoffDraft(thread.providerId); - }, [setSelectedProviderId, syncHandoffDraft, thread.providerId]); - const handoffCap = useMemo(() => { - if (!isHandoffSelection) { - return null; - } - const providerOption = providerOptions.find( - (option) => option.value === selectedProviderId, - ); - const modelLabel = stripModelBrandPrefix( - modelOptions.find((option) => option.value === effectiveSelectedModel) - ?.label ?? effectiveSelectedModel, - providerOption?.brandPrefix, - ); - return ( - - ); - }, [ - effectiveSelectedModel, - handleCancelHandoff, - isHandoffSelection, - modelOptions, - providerOptions, - selectedProviderDisplayName, - selectedProviderId, - ]); const hasSentMessageEdit = sentMessageEdit !== undefined; useEffect(() => { if (hasSentMessageEdit && isHandoffSelection) { @@ -1940,7 +1904,6 @@ export function ThreadDetailPromptArea({ pendingInteraction={pendingInteractionNode} activePromptMode={activePromptMode} composer={shouldHideComposer ? null : bottomComposerConfig} - composerCap={shouldHideComposer ? null : handoffCap} pluginComposerHost={normalPluginComposerHost} pluginComposerScope={normalPluginComposerHost.scope} textEffects={promptTextEffects} From 0f04be9655d04a9049dcc96169bcebc8b7e04424 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:42:29 -0400 Subject: [PATCH 14/36] Use the new-thread icon on the handoff submit button The compact composer drops the label and keeps only the icon so the button no longer overlaps the draft on narrow layouts. Co-Authored-By: Claude Fable 5.1 --- apps/app/src/app.css | 10 +++------- .../components/promptbox/FollowUpPromptBox.tsx | 3 +++ .../components/promptbox/PromptBoxInternal.tsx | 16 +++++++++++----- .../ThreadDetailPromptArea.test.tsx | 6 ++++++ .../thread-detail/ThreadDetailPromptArea.tsx | 1 + 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/app/src/app.css b/apps/app/src/app.css index 58bd39a73d..88605fc955 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -461,19 +461,15 @@ z-index: 1; } - [data-follow-up-composer] - [data-promptbox-submit-action]:not([data-promptbox-submit-labeled]) { + [data-follow-up-composer] [data-promptbox-submit-action] { width: 2rem; height: 2rem; margin-left: 0; padding: 0; } - [data-follow-up-composer] - [data-promptbox-submit-action][data-promptbox-submit-labeled] { - height: 2rem; - margin-left: 0; - padding-inline: 0.625rem; + [data-follow-up-composer] [data-promptbox-submit-label] { + display: none; } [data-follow-up-composer] [data-follow-up-composer-footer] { diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 0d9a1b238b..cd88c7aec1 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -1,3 +1,4 @@ +import type { IconName } from "@bb/shared-ui/icon"; import type { FollowUpSubmitMode } from "@bb/client-core"; import { memo, @@ -141,6 +142,7 @@ export interface FollowUpComposerProps { onSubmit: () => void; onEscape?: () => void; submitLabel?: string; + submitIcon?: IconName; submitTitle?: string; compactPromptPlaceholder: string; promptPlaceholder: string; @@ -730,6 +732,7 @@ function FollowUpPromptBoxWithComposer({ mentionMenuPlacement="top" submission={{ label: composer.submitLabel, + icon: composer.submitIcon, onStop: onStopRuntime, isSubmitting: composer.isFollowUpSubmitting || isStopping, disabled: diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 441918222f..748b38c12f 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -47,7 +47,7 @@ import { type VoiceUnsupportedReason, } from "@/hooks/voice-input-support"; import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; +import { Icon, type IconName } from "@bb/shared-ui/icon"; import { Tooltip, TooltipContent, @@ -222,6 +222,7 @@ export interface PromptBoxSubmissionConfig { disabled?: boolean; disabledReason?: string; label?: string; + icon?: IconName; title?: string; isRunning?: boolean; onStop?: () => void; @@ -232,6 +233,7 @@ interface PromptSubmitButtonProps { canSubmit: boolean; className: string; disabledReason: string | undefined; + icon: IconName | undefined; isBusy: boolean; isCompact: boolean; label: string | undefined; @@ -245,6 +247,7 @@ function PromptSubmitButton({ canSubmit, className, disabledReason, + icon, isBusy, isCompact, label, @@ -260,7 +263,6 @@ function PromptSubmitButton({ const button = ( @@ -1198,6 +1202,7 @@ export function PromptBoxInternal({ disabled: submitDisabled = false, disabledReason: submitDisabledReason, label: submitLabel, + icon: submitIcon, title: submitTitle = "Submit (Enter)", isRunning = false, onStop, @@ -3377,6 +3382,7 @@ export function PromptBoxInternal({ ) : ( { onEscape?: () => void; onSubmit: () => void; submitLabel?: string; + submitIcon?: string; submitTitle?: string; submitMode: { kind: string; reason?: string }; } | null; @@ -184,6 +185,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { {composer?.submitTitle ?? "Submit"}
{composer?.submitLabel ?? ""}
+
{composer?.submitIcon ?? ""}
{suppressPluginComposerCustomizations ? "true" : "false"}
@@ -1873,6 +1875,9 @@ describe("ThreadDetailPromptArea", () => { expect(mocks.toastMessage).not.toHaveBeenCalled(); 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)", ); @@ -1901,6 +1906,7 @@ describe("ThreadDetailPromptArea", () => { ); expect(screen.getByTestId("submit-label").textContent).toBe(""); + expect(screen.getByTestId("submit-icon").textContent).toBe(""); expect(screen.getByTestId("submit-title").textContent).toBe("Submit"); }); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 47a1327191..323b8b7c94 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1241,6 +1241,7 @@ export function ThreadDetailPromptArea({ ...(isHandoffSelection ? { submitLabel: "New thread", + submitIcon: "MessageSquarePlus", submitTitle: "Create new thread (Enter)", } : {}), From b6ed4a2d8431ecc7d9823bc1dbf0f78237532adb Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:50:13 -0400 Subject: [PATCH 15/36] Let the current-thread tab leave handoff mode Clicking the struck-through tab for the thread's own provider now exits handoff mode and, when the composer was on another provider, switches it back so the seeded reference and submit button return to normal. Co-Authored-By: Claude Fable 5.1 --- .../pickers/ModelReasoningPicker.test.tsx | 34 +++++++++++++++++-- .../pickers/ModelReasoningPicker.tsx | 19 ++++++++--- .../promptbox/ExecutionControls.test.tsx | 5 +-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index 6abc6712a3..efab0b4666 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -686,8 +686,16 @@ describe("ModelReasoningPicker", () => { expect( screen.getByRole("button", { name: "Back to model picker" }), ).not.toBeNull(); - const currentTab = screen.getByTitle("Codex (current thread)"); - expect(currentTab).toHaveProperty("disabled", true); + 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(); @@ -720,6 +728,28 @@ describe("ModelReasoningPicker", () => { expect(trigger.getAttribute("aria-expanded")).toBe("false"); }); + 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("loads provider models on the compose-selected host", async () => { renderPicker({ providerRouting: { hostId: "host-remote" } }); diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index f9172401d4..752d506127 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -625,6 +625,15 @@ export function ModelReasoningPicker({ setSearchQuery(""); setActiveIndex(-1); }, []); + const returnToSourceProvider = useCallback(() => { + if (handoff === undefined) { + return; + } + exitHandoffMode(); + if (selectedProviderId !== handoff.sourceProviderId) { + onSelectedProviderChange?.(handoff.sourceProviderId); + } + }, [exitHandoffMode, handoff, onSelectedProviderChange, selectedProviderId]); const paneContext = useOptionalPaneContext(); const isFocusedPane = paneContext?.isFocused ?? true; @@ -1017,13 +1026,13 @@ export function ModelReasoningPicker({ ? `${provider.label} (current thread)` : provider.label } - disabled={isHandoffSource} onMouseDown={(event) => event.preventDefault()} onClick={() => { - if ( - isHandoffSource || - provider.value === activeProviderId - ) { + if (isHandoffSource) { + returnToSourceProvider(); + return; + } + if (provider.value === activeProviderId) { return; } if (handoffMode) { diff --git a/apps/app/src/components/promptbox/ExecutionControls.test.tsx b/apps/app/src/components/promptbox/ExecutionControls.test.tsx index cdc9831ddb..3d00e0d4e2 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.test.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.test.tsx @@ -114,10 +114,7 @@ describe("ExecutionControls", () => { expect( screen.getByRole("button", { name: "Back to model picker" }), ).not.toBeNull(); - expect(screen.getByTitle("Codex (current thread)")).toHaveProperty( - "disabled", - true, - ); + expect(screen.getByTitle("Codex (current thread)")).not.toBeNull(); expect(screen.getByTitle("Claude Code")).toHaveProperty("disabled", false); }); From 5726250984e1f8008ab2481f26f8ca3962f952b9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:58:59 -0400 Subject: [PATCH 16/36] Assert on the submit label in the picker-flow handoff test Co-Authored-By: Claude Fable 5.1 --- .../views/thread-detail/ThreadDetailPromptArea.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index b27c2a03b0..0f9771f608 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -1852,9 +1852,10 @@ describe("ThreadDetailPromptArea", () => { screen.getByRole("button", { name: "Complete handoff flow" }), ); - expect( - screen.getByLabelText("Handoff to new thread").textContent, - ).toContain("Claude Code"); + 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)", ); From c4a84e77cd0305895a82868b92468a4ca555b31b Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 13:21:03 -0400 Subject: [PATCH 17/36] Move the handoff entry to the top of the picker The footer placement was easy to miss. The entry now sits above the provider tabs as a full-width row filled with the shared selection grey, with the new-thread icon and a chevron marking that it opens the nested handoff mode. Co-Authored-By: Claude Opus 5 (1M context) --- .../pickers/ModelReasoningPicker.tsx | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 752d506127..8a63eca8c7 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -20,7 +20,7 @@ import { } from "./model-brand-prefix"; import { fastServiceTierLabel } from "@/lib/reasoning-labels"; import { Button } from "@bb/shared-ui/button"; -import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { Icon } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; import { COARSE_POINTER_ICON_SIZE_CLASS, @@ -1003,6 +1003,11 @@ export function ModelReasoningPicker({ > {handoffMode ? : null} + {handoff !== undefined && + !handoffMode && + handoffProviderOptions.length > 0 ? ( + + ) : null} {showProviderTabs ? (
) : null} - - {handoff !== undefined && - !handoffMode && - handoffProviderOptions.length > 0 ? ( - <> -
-
- -
- - ) : null}
@@ -1302,32 +1292,37 @@ function HandoffModeHeader({ onBack }: { onBack: () => void }) { ); } -function MenuActionButton({ - label, - iconName, - onClick, -}: { - label: string; - iconName: IconName; - onClick: () => void; -}) { - const { hoverProps } = useMenuItemHover(); +function HandoffMenuEntry({ onClick }: { onClick: () => void }) { const isCompactViewport = useIsCompactViewport(); return ( - + +
); } From 0ed179ba83cb9dc0724d3ae865dc98456988ac81 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 13:35:08 -0400 Subject: [PATCH 18/36] Revert "Move the handoff entry to the top of the picker" This reverts commit c4a84e77cd0305895a82868b92468a4ca555b31b. --- .../pickers/ModelReasoningPicker.tsx | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 8a63eca8c7..752d506127 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -20,7 +20,7 @@ import { } from "./model-brand-prefix"; import { fastServiceTierLabel } from "@/lib/reasoning-labels"; import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; +import { Icon, type IconName } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; import { COARSE_POINTER_ICON_SIZE_CLASS, @@ -1003,11 +1003,6 @@ export function ModelReasoningPicker({ > {handoffMode ? : null} - {handoff !== undefined && - !handoffMode && - handoffProviderOptions.length > 0 ? ( - - ) : null} {showProviderTabs ? (
) : null} + + {handoff !== undefined && + !handoffMode && + handoffProviderOptions.length > 0 ? ( + <> +
+
+ +
+ + ) : null}
@@ -1292,37 +1302,32 @@ function HandoffModeHeader({ onBack }: { onBack: () => void }) { ); } -function HandoffMenuEntry({ onClick }: { onClick: () => void }) { +function MenuActionButton({ + label, + iconName, + onClick, +}: { + label: string; + iconName: IconName; + onClick: () => void; +}) { + const { hoverProps } = useMenuItemHover(); const isCompactViewport = useIsCompactViewport(); return ( -
- -
+ + {label} + ); } From 8b9ec6071070d6a9ed191e437c5063c36f472646 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 13:35:46 -0400 Subject: [PATCH 19/36] Tint the handoff-mode header with the selection grey The header marking that submitting creates a new thread blended into the picker's own chrome. It now carries the shared selection fill so the mode is obvious, with its layout and controls unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- apps/app/src/components/pickers/ModelReasoningPicker.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 752d506127..d4b30a8259 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -1276,14 +1276,8 @@ export function ModelReasoningPicker({ } function HandoffModeHeader({ onBack }: { onBack: () => void }) { - const isCompactViewport = useIsCompactViewport(); return ( -
+
- ); -} - function MenuSectionLabel({ children, className, @@ -1631,6 +1602,35 @@ function MenuRowButton({ ); } + +function MenuActionButton({ + label, + iconName, + onClick, +}: { + label: string; + iconName: IconName; + onClick: () => void; +}) { + const { hoverProps } = useMenuItemHover(); + const isCompactViewport = useIsCompactViewport(); + return ( + + ); +} interface ModelSearchInputProps { inputRef: React.RefObject; query: string; diff --git a/apps/app/src/components/promptbox/ExecutionControls.test.tsx b/apps/app/src/components/promptbox/ExecutionControls.test.tsx index 3d00e0d4e2..097cb9e6a8 100644 --- a/apps/app/src/components/promptbox/ExecutionControls.test.tsx +++ b/apps/app/src/components/promptbox/ExecutionControls.test.tsx @@ -96,28 +96,6 @@ describe("ExecutionControls", () => { expect(trigger.textContent).not.toContain("Failed to load models"); }); - it("offers the in-picker handoff flow when configured", () => { - renderExecutionControls({ - ...makeExecutionControlsProps(vi.fn()), - handoff: { sourceProviderId: "codex", onSelect: vi.fn() }, - }); - - fireEvent.click( - screen.getByRole("button", { - name: "Provider, model and reasoning", - }), - ); - fireEvent.click( - screen.getByRole("button", { name: "Handoff to new thread" }), - ); - - expect( - screen.getByRole("button", { name: "Back to model picker" }), - ).not.toBeNull(); - expect(screen.getByTitle("Codex (current thread)")).not.toBeNull(); - expect(screen.getByTitle("Claude Code")).toHaveProperty("disabled", false); - }); - it("maps disabled fast mode to the explicit default service tier", () => { const onServiceTierChange = vi.fn(); renderExecutionControls({ diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 549bed2d3a..ab71a5bfe3 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -76,7 +76,6 @@ const mocks = vi.hoisted(() => ({ setQueuedMessageGroupBoundaryMutateAsync: vi.fn(), stopThreadMutate: vi.fn(), toastError: vi.fn(), - toastMessage: vi.fn(), unarchiveThreadMutate: vi.fn(), uploadPromptAttachmentMutateAsync: vi.fn(), updateQueuedMessageMutateAsync: vi.fn(), @@ -310,9 +309,6 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { ) : null} -
- {execution.provider.onChange ? "true" : "false"} -
{execution.provider.onChange ? ( <> ) : null} -
- {execution.handoff ? "true" : "false"} -
{execution.handoff ? ( From 7429699197dcc0e0b169cc1fe7044fbabe55a3f8 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 12 Sep 2026 17:59:10 -0400 Subject: [PATCH 34/36] Match handoff header to sort menu label styling --- apps/app/src/components/pickers/ModelReasoningPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 5209277c84..e8c9579814 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -1353,7 +1353,7 @@ function HandoffModeHeader({ onBack }: { onBack: () => void }) { > - + Handoff to new thread
From 47f8d335a7132bae03f5646b19363fc6f79755bc Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 12 Sep 2026 18:14:39 -0400 Subject: [PATCH 35/36] Use picker background for handoff header and provider tabs --- .../src/components/pickers/ModelReasoningPicker.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index e8c9579814..e845a9d6be 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -1067,10 +1067,7 @@ export function ModelReasoningPicker({ {handoffMode ? : null} {showProviderTabs ? (
{providerOptions.map((provider) => { const TabIcon = provider.icon; @@ -1334,14 +1331,8 @@ export function ModelReasoningPicker({ } function HandoffModeHeader({ onBack }: { onBack: () => void }) { - const isCompactViewport = useIsCompactViewport(); return ( -
+