;
@@ -117,18 +126,28 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => {
onChangeMessage: (message: string, mentions: []) => void;
onEscape?: () => void;
onSubmit: () => void;
+ submitLabel?: string;
+ submitIcon?: string;
submitTitle?: string;
submitMode: { kind: string; reason?: string };
} | null;
environmentSummary?: ReactNode;
execution: {
- footerAction?: {
- label: string;
- onClick: () => void;
- };
+ providerRouting: { environmentId?: string; hostId?: string };
model: {
active?: { model: string } | null;
};
+ provider: {
+ selectedId: string;
+ onChange?: (value: string) => void;
+ };
+ handoff?: {
+ onSelect: (selection: {
+ providerId: string;
+ model: string;
+ reasoningLevel: "medium";
+ }) => void;
+ };
reasoning: { value: string };
serviceTier?: { value?: string };
};
@@ -173,9 +192,23 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => {
{composer?.submitTitle ?? "Submit"}
+ {composer?.submitLabel ?? ""}
+ {composer?.submitIcon ?? ""}
{suppressPluginComposerCustomizations ? "true" : "false"}
+ {activePromptMode?.mode}
+
+ {execution.provider.selectedId}
+
+
+ {execution.providerRouting.environmentId}
+
+
+ {typeahead.command?.suggestions
+ .map((command) => command.name)
+ .join(",")}
+
{execution.model.active?.model}
{execution.reasoning.value}
@@ -276,9 +309,34 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => {
>
) : null}
- {execution.footerAction ? (
-
@@ -452,13 +510,19 @@ vi.mock("@/components/ui/app-toast", () => ({
}));
vi.mock("@/hooks/useCommandSuggestions", () => ({
- useCommandSuggestions: () => ({
+ useCommandSuggestions: ({
+ providerId,
+ commandScope,
+ }: {
+ providerId: string;
+ commandScope: string;
+ }) => ({
hasMore: false,
isError: false,
isLoading: false,
isLoadingMore: false,
loadMore: vi.fn(),
- suggestions: [],
+ suggestions: [{ name: `${providerId}:${commandScope}` }],
trigger: null,
}),
}));
@@ -476,38 +540,56 @@ vi.mock("@/hooks/usePromptMentions", () => ({
}),
}));
-vi.mock("@/hooks/useThreadCreationOptions", () => ({
- useThreadCreationOptions: (options: unknown) => {
- mocks.useThreadCreationOptions(options);
- return {
- activeModel: null,
- executionInputSources: {},
- hasMultipleProviders: false,
- isLoadingModels: false,
- modelLoadError: null,
- modelLoadFailed: false,
- modelOptions: [],
- moreModelOptions: [],
- permissionMode: "auto",
- permissionModeOptions: [],
- providerOptions: [],
- reasoningLevel: "medium",
- reasoningOptions: [],
- selectedModel: "gpt-5",
- selectedProviderComposerActions: [],
- selectedProviderDisplayName: "Codex",
- selectedProviderId: "codex",
- serviceTier: undefined,
- serviceTierSupportByProvider: {},
- setPermissionMode: vi.fn(),
- setReasoningLevel: vi.fn(),
- setSelectedModel: vi.fn(),
- setServiceTier: vi.fn(),
- supportsPermissionModeSelection: true,
- supportsServiceTier: false,
- };
- },
-}));
+vi.mock("@/hooks/useThreadCreationOptions", async () => {
+ const { useState } = await import("react");
+ return {
+ useThreadCreationOptions: (options: {
+ initialProviderId: string;
+ initialPermissionMode?: PermissionMode;
+ }) => {
+ mocks.useThreadCreationOptions(options);
+ const [selectedProviderId, setSelectedProviderId] = useState(
+ options.initialProviderId,
+ );
+ const isClaude = selectedProviderId === "claude-code";
+ return {
+ activeModel: null,
+ executionInputSources: {},
+ executionOptionsRouting: { hostId: "host_1" },
+ providers: [],
+ hasMultipleProviders: true,
+ isLoadingModels: false,
+ modelLoadError: null,
+ modelLoadFailed: false,
+ modelOptions: [],
+ moreModelOptions: [],
+ permissionMode: options.initialPermissionMode ?? "auto",
+ permissionModeOptions: [],
+ providerOptions: [
+ { value: "codex", label: "Codex" },
+ { value: "claude-code", label: "Claude Code" },
+ ],
+ reasoningLevel: "medium",
+ reasoningOptions: [],
+ selectedModel: isClaude ? "claude-opus-5" : "gpt-5",
+ selectedProviderComposerActions: [],
+ selectedProviderDisplayName: isClaude ? "Claude Code" : "Codex",
+ selectedProviderId,
+ serviceTier: undefined,
+ serviceTierSupportByProvider: {},
+ setPermissionMode: vi.fn(),
+ setReasoningLevel: vi.fn(),
+ setProviderModelReasoning: ({ providerId }: { providerId: string }) =>
+ setSelectedProviderId(providerId),
+ setSelectedModel: vi.fn(),
+ setSelectedProviderId,
+ setServiceTier: vi.fn(),
+ supportsPermissionModeSelection: true,
+ supportsServiceTier: false,
+ };
+ },
+ };
+});
vi.mock("@/hooks/mutations/project-mutations", () => ({
useUploadPromptAttachment: () => ({
@@ -525,6 +607,10 @@ vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({
isPending: false,
mutate: mocks.clearThreadGoalMutate,
}),
+ useCreateThread: () => ({
+ isPending: false,
+ mutateAsync: mocks.createThreadMutateAsync,
+ }),
useCreateThreadQueuedMessage: () => ({
isPending: false,
mutateAsync: mocks.createQueuedMessageMutateAsync,
@@ -743,7 +829,7 @@ function buildPromptAreaElement({
resolveMentionLink={() => null}
sendMessage={{
isPending: false,
- mutateAsync: vi.fn(),
+ mutateAsync: mocks.sendMessageMutateAsync,
}}
sentMessageEdit={sentMessageEdit}
steerActiveThreadOnEnter={false}
@@ -766,11 +852,24 @@ beforeEach(() => {
mocks.defaultExecutionOptions = null;
mocks.pluginComposerHost = null;
mocks.promptDraft.text = "";
+ mocks.promptDraft.mentions = [];
+ mocks.promptDraft.attachments = [];
mocks.promptDraft.getCurrent.mockImplementation(() => ({
attachments: mocks.promptDraft.attachments,
mentions: mocks.promptDraft.mentions,
text: mocks.promptDraft.text,
}));
+ mocks.promptDraft.setDraft.mockImplementation(
+ (draft: {
+ attachments: PromptDraftAttachment[];
+ mentions: PromptTextMention[];
+ text: string;
+ }) => {
+ mocks.promptDraft.attachments = draft.attachments;
+ mocks.promptDraft.mentions = draft.mentions;
+ mocks.promptDraft.text = draft.text;
+ },
+ );
mocks.queuedMessages = [];
mocks.updateQueuedMessageMutateAsync.mockResolvedValue(undefined);
mocks.useThreadCreationOptions.mockClear();
@@ -1333,7 +1432,7 @@ describe("ThreadDetailPromptArea", () => {
).toBe("Second queued draft");
});
- it("shows the queued execution values as read-only while editing", () => {
+ it("keeps queued execution and commands source-locked during a bottom handoff", () => {
mocks.defaultExecutionOptions = {
model: "bottom-model",
permissionMode: "auto",
@@ -1351,6 +1450,7 @@ describe("ThreadDetailPromptArea", () => {
];
renderPromptArea();
+ fireEvent.click(screen.getByRole("button", { name: "Switch provider" }));
fireEvent.click(
screen.getByRole("button", { name: "Edit queued message 1" }),
);
@@ -1358,6 +1458,29 @@ describe("ThreadDetailPromptArea", () => {
screen.getByTestId("inline-queued-message-editor"),
);
+ expect(inlineEditor.getByTestId("selected-provider").textContent).toBe(
+ "codex",
+ );
+ expect(inlineEditor.getByTestId("command-suggestions").textContent).toBe(
+ "codex:thread",
+ );
+ const inlineHost = screen.getByTestId("inline-queued-message-editor");
+ for (const name of ["Switch provider", "Complete handoff flow"]) {
+ expect(inlineEditor.queryByRole("button", { name })).toBeNull();
+ expect(screen.getByRole("button", { name })).not.toBeNull();
+ }
+ expect(
+ screen
+ .getAllByTestId("selected-provider")
+ .filter((element) => !inlineHost.contains(element))
+ .map((element) => element.textContent),
+ ).toEqual(["claude-code"]);
+ expect(
+ screen
+ .getAllByTestId("command-suggestions")
+ .filter((element) => !inlineHost.contains(element))
+ .map((element) => element.textContent),
+ ).toEqual(["claude-code:new-thread"]);
expect(inlineEditor.getByTestId("selected-model").textContent).toBe(
"queued-model",
);
@@ -1376,9 +1499,6 @@ describe("ThreadDetailPromptArea", () => {
expect(inlineEditor.getByTestId("permission-read-only").textContent).toBe(
"true",
);
- expect(
- inlineEditor.queryByRole("button", { name: "Handoff to new thread" }),
- ).toBeNull();
});
it("dismisses an inline edit when its thread changes or its live row disappears", async () => {
@@ -1754,32 +1874,189 @@ describe("ThreadDetailPromptArea", () => {
expect(screen.getByText("Model fallback")).toBeTruthy();
});
- it("opens root compose with a handoff seed for the current thread", () => {
+ it.each(["Switch provider", "Complete handoff flow"])(
+ "%s prepares a handoff and restores the draft on return",
+ (entryAction) => {
+ mocks.promptDraft.text = "Keep going";
+ renderPromptArea();
+ expect(screen.getByTestId("submit-title").textContent).toBe("Submit");
+ expect(screen.getByTestId("submit-label").textContent).toBe("");
+
+ fireEvent.click(screen.getByRole("button", { name: entryAction }));
+
+ expect(screen.getByTestId("submit-label").textContent).toBe("New thread");
+ expect(screen.getByTestId("submit-icon").textContent).toBe(
+ "MessageSquarePlus",
+ );
+ expect(screen.getByTestId("submit-title").textContent).toBe(
+ "Create new thread (Enter)",
+ );
+ expect(screen.getByTestId("selected-model").textContent).toBe(
+ "claude-opus-5",
+ );
+ expect(screen.getByTestId("submit-mode").textContent).toBe("ready:");
+ expect(mocks.promptDraft.setDraft).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ text: "Continue from @thread:thr_1\n\nKeep going",
+ }),
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Switch provider back" }),
+ );
+
+ expect(mocks.promptDraft.setDraft).toHaveBeenLastCalledWith({
+ attachments: [],
+ mentions: [],
+ text: "Keep going",
+ });
+ expect(screen.getByTestId("submit-label").textContent).toBe("");
+ expect(screen.getByTestId("submit-icon").textContent).toBe("");
+ expect(screen.getByTestId("submit-title").textContent).toBe("Submit");
+ },
+ );
+
+ it("shows destination permissions instead of the source active Plan mode", async () => {
+ mocks.defaultExecutionOptions = {
+ model: "gpt-5",
+ permissionMode: "full",
+ reasoningLevel: "medium",
+ serviceTier: "default",
+ source: "client/turn/requested",
+ };
+ mocks.createThreadMutateAsync.mockResolvedValue({
+ id: "thr_new",
+ projectId: "proj_1",
+ });
+ renderPromptArea({ activePromptMode: activePlan });
+ expect(screen.getByTestId("active-permission-mode").textContent).toBe(
+ "plan",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Switch provider" }));
+ expect(screen.getByTestId("active-permission-mode").textContent).toBe("");
+ expect(screen.getByTestId("selected-permission").textContent).toBe("full");
+ fireEvent.click(screen.getByRole("button", { name: "Submit composer" }));
+ await waitFor(() =>
+ expect(mocks.createThreadMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ providerId: "claude-code",
+ permissionMode: "full",
+ }),
+ ),
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Switch provider back" }),
+ );
+ expect(screen.getByTestId("active-permission-mode").textContent).toBe(
+ "plan",
+ );
+ });
+
+ it.each([false, true])(
+ "keeps the destination model after a source fallback (scheduled: %s)",
+ async (scheduled) => {
+ const thread = makeThread({
+ providerId: "claude-code",
+ environmentId: "env_1",
+ });
+ mocks.createThreadMutateAsync.mockResolvedValue({
+ id: "thr_new",
+ projectId: "proj_1",
+ });
+ const { rerender } = renderPromptArea({ thread });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Switch provider back" }),
+ );
+ rerender(
+ buildPromptAreaElement({
+ thread,
+ modelFallback: {
+ sourceSeq: 43,
+ detectedAt: 123,
+ originalModel: "claude-fable-5",
+ fallbackModel: "claude-opus-4-8",
+ reason: "refusal",
+ message: "Switched to Opus.",
+ },
+ }),
+ );
+ expect(screen.getByTestId("selected-model").textContent).toBe("gpt-5");
+ expect(screen.getByTestId("preview-environment").textContent).toBe(
+ "env_1",
+ );
+ if (scheduled) {
+ fireEvent.click(
+ screen.getByRole("button", { name: "Capture plugin host" }),
+ );
+ await act(async () => {
+ await mocks.pluginComposerHost?.submit?.({ sendAt: 1234567890 });
+ });
+ } else {
+ fireEvent.click(
+ screen.getByRole("button", { name: "Submit composer" }),
+ );
+ }
+ await waitFor(() =>
+ expect(mocks.createThreadMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ providerId: "codex",
+ model: "gpt-5",
+ ...(scheduled ? { sendAt: 1234567890 } : {}),
+ }),
+ ),
+ );
+ },
+ );
+
+ it("creates a new thread from the draft as typed and navigates to it", async () => {
+ mocks.promptDraft.text = "Refactor the tests";
+ mocks.createThreadMutateAsync.mockResolvedValue({
+ id: "thr_new",
+ projectId: "proj_source",
+ });
+
renderPromptArea({
thread: makeThread({
environmentId: "env_1",
id: "thr_source",
projectId: "proj_source",
+ runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null },
+ status: "active",
title: "Source thread",
titleFallback: null,
}),
});
+ fireEvent.click(screen.getByRole("button", { name: "Switch provider" }));
+ fireEvent.click(screen.getByRole("button", { name: "Submit composer" }));
- fireEvent.click(
- screen.getByRole("button", { name: "Handoff to new thread" }),
- );
-
- expect(mocks.navigate).toHaveBeenCalledWith("/projects/proj_source", {
- state: {
- focusPrompt: true,
- reuseEnvironmentId: "env_1",
- [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: {
- environmentId: "env_1",
- projectId: "proj_source",
- sourceThreadId: "thr_source",
- sourceThreadTitle: "Source thread",
- },
- },
- });
+ await waitFor(() =>
+ expect(mocks.navigate).toHaveBeenCalledWith(
+ "/projects/proj_source/threads/thr_new",
+ ),
+ );
+ expect(mocks.createThreadMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ environment: { type: "reuse", environmentId: "env_1" },
+ input: [
+ expect.objectContaining({
+ type: "text",
+ text: "Continue from @thread:thr_source\n\nRefactor the tests",
+ mentions: [
+ expect.objectContaining({
+ start: 14,
+ end: 32,
+ resource: expect.objectContaining({ threadId: "thr_source" }),
+ }),
+ ],
+ }),
+ ],
+ model: "claude-opus-5",
+ projectId: "proj_source",
+ providerId: "claude-code",
+ }),
+ );
+ expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled();
+ expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled();
+ expect(mocks.promptDraft.clearIfCurrentMatches).toHaveBeenCalledTimes(1);
});
});
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index f28e88f15b..8f1cb08f4e 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -55,6 +55,7 @@ import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflow
import { ThreadBackgroundCommandsCard } from "@/components/promptbox/banner/ThreadBackgroundCommandsCard";
import { ThreadModelFallbackCard } from "@/components/promptbox/banner/ThreadModelFallbackCard";
import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame";
+import type { ModelReasoningPickerHandoffSelection } from "@/components/pickers/ModelReasoningPicker";
import type {
WorkspaceChangedFileSelection,
WorkspaceChangedFilesSection,
@@ -82,6 +83,7 @@ import {
type InlineQueuedMessageEditState,
} from "@/components/thread/embedded-chat";
import {
+ useCreateThread,
useCreateThreadQueuedMessage,
useCancelThreadPlan,
useClearThreadGoal,
@@ -100,9 +102,14 @@ import {
} from "@/lib/mutation-errors";
import { promptHistoryEntriesToDrafts } from "@/lib/prompt-history";
import { usePromptHistoryEnabled } from "@/hooks/usePromptHistoryEnabled";
-import { getProjectComposeRoutePath } from "@/lib/route-paths";
+import { getThreadRoutePath } from "@/lib/route-paths";
import { getThreadDisplayTitle } from "@/lib/thread-title";
-import { buildThreadHandoffLocationState } from "@bb/client-core";
+import {
+ buildThreadHandoffCreateRequest,
+ buildThreadHandoffFollowUpDraft,
+ stripThreadHandoffPrefix,
+ type ThreadHandoffCreateSeed,
+} from "@bb/client-core";
import {
emptyPromptDraftState,
promptDraftToInput,
@@ -128,6 +135,7 @@ import {
} from "@bb/client-core";
const ignorePromptBannerFileClick = () => {};
+const ignoreToastedCreateThreadError = () => {};
export interface ThreadDetailSentMessageEdit {
draft: PromptDraftState;
@@ -457,6 +465,7 @@ export function ThreadDetailPromptArea({
const cancelThreadPlan = useCancelThreadPlan();
const clearThreadGoal = useClearThreadGoal();
const unarchiveThread = useUnarchiveThread();
+ const createThread = useCreateThread();
const projectName = useProjectDisplayName(
thread.projectId === PERSONAL_PROJECT_ID ? undefined : thread.projectId,
);
@@ -604,6 +613,9 @@ export function ThreadDetailPromptArea({
const {
executionOptionsRouting,
selectedProviderId,
+ setSelectedProviderId,
+ setProviderModelReasoning,
+ providers,
providerOptions,
hasMultipleProviders,
selectedProviderComposerActions,
@@ -649,7 +661,9 @@ export function ThreadDetailPromptArea({
string | null
>(null);
const isFallbackModelActive =
- modelFallback !== null && overriddenFallbackIdentity !== fallbackIdentity;
+ selectedProviderId === thread.providerId &&
+ modelFallback !== null &&
+ overriddenFallbackIdentity !== fallbackIdentity;
const effectiveSelectedModel = isFallbackModelActive
? modelFallback.fallbackModel
: (activeModel?.model ?? selectedModel);
@@ -662,15 +676,130 @@ export function ThreadDetailPromptArea({
},
[fallbackIdentity, setSelectedModel],
);
+ const isHandoffProviderId = useCallback(
+ (providerId: string) =>
+ providerId.length > 0 &&
+ providerId !== thread.providerId &&
+ providerOptions.some((option) => option.value === thread.providerId),
+ [providerOptions, thread.providerId],
+ );
+ const isHandoffSelection = isHandoffProviderId(selectedProviderId);
+ const sourceThreadDisplayTitle = getThreadDisplayTitle({
+ id: thread.id,
+ title: thread.title,
+ titleFallback: thread.titleFallback,
+ });
+ const handoffSeed = useMemo(
+ () => ({
+ environmentId: thread.environmentId,
+ projectId: thread.projectId,
+ sourceThreadId: thread.id,
+ sourceThreadTitle: sourceThreadDisplayTitle,
+ }),
+ [
+ sourceThreadDisplayTitle,
+ thread.environmentId,
+ thread.id,
+ thread.projectId,
+ ],
+ );
+ const syncHandoffDraft = useCallback(
+ (nextProviderId: string) => {
+ const currentDraft = promptDraft.getCurrent();
+ if (isHandoffProviderId(nextProviderId)) {
+ const seededDraft = buildThreadHandoffFollowUpDraft(
+ handoffSeed,
+ currentDraft,
+ );
+ if (seededDraft !== currentDraft) {
+ promptDraft.setDraft(seededDraft);
+ }
+ return;
+ }
+ const restoredDraft = stripThreadHandoffPrefix(handoffSeed, currentDraft);
+ if (restoredDraft !== null) {
+ promptDraft.setDraft(restoredDraft);
+ }
+ },
+ [handoffSeed, isHandoffProviderId, promptDraft],
+ );
+ const handleProviderChange = useCallback(
+ (providerId: string) => {
+ if (providerId === selectedProviderId) {
+ return;
+ }
+ if (fallbackIdentity !== null) {
+ setOverriddenFallbackIdentity(fallbackIdentity);
+ }
+ setSelectedProviderId(providerId);
+ syncHandoffDraft(providerId);
+ },
+ [
+ fallbackIdentity,
+ selectedProviderId,
+ setSelectedProviderId,
+ syncHandoffDraft,
+ ],
+ );
+ const handleHandoffSelect = useCallback(
+ (selection: ModelReasoningPickerHandoffSelection) => {
+ if (fallbackIdentity !== null) {
+ setOverriddenFallbackIdentity(fallbackIdentity);
+ }
+ setProviderModelReasoning(selection);
+ syncHandoffDraft(selection.providerId);
+ },
+ [fallbackIdentity, setProviderModelReasoning, syncHandoffDraft],
+ );
+ useEffect(() => {
+ if (isHandoffSelection) {
+ return;
+ }
+ const restoredDraft = stripThreadHandoffPrefix(
+ handoffSeed,
+ promptDraft.getCurrent(),
+ );
+ if (restoredDraft !== null) {
+ promptDraft.setDraft(restoredDraft);
+ }
+ }, [handoffSeed, isHandoffSelection, promptDraft]);
+ const hasSentMessageEdit = sentMessageEdit !== undefined;
+ useEffect(() => {
+ if (hasSentMessageEdit && isHandoffSelection) {
+ setSelectedProviderId(thread.providerId);
+ syncHandoffDraft(thread.providerId);
+ }
+ }, [
+ hasSentMessageEdit,
+ isHandoffSelection,
+ setSelectedProviderId,
+ syncHandoffDraft,
+ thread.providerId,
+ ]);
const { typeaheadConfig, promptActions } = useComposerTypeahead({
projectId: thread.projectId,
mentionsProjectId: projectId,
- providerId: thread.providerId,
+ providerId: selectedProviderId,
+ commandScope: isHandoffSelection ? "new-thread" : "thread",
environmentId: thread.environmentId,
currentThreadId: thread.id,
selectedProviderComposerActions,
resolveMentionLink,
});
+ const {
+ typeaheadConfig: inlineTypeaheadConfig,
+ promptActions: inlinePromptActions,
+ } = useComposerTypeahead({
+ projectId: thread.projectId,
+ mentionsProjectId: projectId,
+ providerId: thread.providerId,
+ environmentId: thread.environmentId,
+ currentThreadId: thread.id,
+ selectedProviderComposerActions: providers.find(
+ (provider) => provider.id === thread.providerId,
+ )?.composerActions,
+ resolveMentionLink,
+ });
const runtimeDisplayStatus = thread.runtime.displayStatus;
const shouldSteerWhenReady =
runtimeDisplayStatus === "provisioning" ||
@@ -709,6 +838,7 @@ export function ThreadDetailPromptArea({
const isFollowUpSubmitting =
sendMessage.isPending ||
createQueuedMessage.isPending ||
+ createThread.isPending ||
isFollowUpShortcutSending;
const handleStopThread = useCallback(() => {
stopThread.mutate(thread.id);
@@ -719,7 +849,16 @@ export function ThreadDetailPromptArea({
const handleClearGoal = useCallback(() => {
clearThreadGoal.mutate(thread.id);
}, [clearThreadGoal, thread.id]);
- const submitMode: FollowUpSubmitMode = useMemo(() => {
+ const submitMode = useMemo(() => {
+ if (isHandoffSelection && !isStopRequested) {
+ if (effectiveSelectedModel.length > 0) {
+ return { kind: "ready" };
+ }
+ return {
+ kind: "blocked",
+ reason: modelLoadFailed ? "unavailable" : "loading-execution-options",
+ };
+ }
return buildFollowUpSubmitMode({
hasPendingInteraction,
isDefaultExecutionOptionsLoading,
@@ -729,9 +868,12 @@ export function ThreadDetailPromptArea({
runtimeDisplayStatus,
});
}, [
+ effectiveSelectedModel,
handleStopThread,
hasPendingInteraction,
isDefaultExecutionOptionsLoading,
+ isHandoffSelection,
+ modelLoadFailed,
pendingInteractionsInitialLoading,
isStopRequested,
runtimeDisplayStatus,
@@ -800,9 +942,69 @@ export function ThreadDetailPromptArea({
supportsServiceTier,
]);
+ const createHandoffThread = useCallback(
+ async (submittedDraft: PromptDraftState, sendAt?: number) => {
+ const request = buildThreadHandoffCreateRequest({
+ execution: {
+ providerId: selectedProviderId,
+ model: effectiveSelectedModel,
+ reasoningLevel,
+ serviceTier,
+ supportsServiceTier,
+ permissionMode,
+ executionInputSources,
+ },
+ draft: submittedDraft,
+ seed: handoffSeed,
+ ...(sendAt === undefined ? {} : { sendAt }),
+ });
+ if (request === null) {
+ return false;
+ }
+ const clearedSubmittedDraft =
+ promptDraft.clearIfCurrentMatches(submittedDraft);
+ setBottomAttachmentError(null);
+ try {
+ const created = await createThread.mutateAsync(request);
+ navigate(
+ getThreadRoutePath({
+ projectId: created.projectId,
+ threadId: created.id,
+ }),
+ );
+ } catch (error) {
+ if (clearedSubmittedDraft) {
+ promptDraft.restoreIfEmpty(submittedDraft);
+ }
+ throw error;
+ }
+ return true;
+ },
+ [
+ createThread,
+ effectiveSelectedModel,
+ executionInputSources,
+ handoffSeed,
+ navigate,
+ permissionMode,
+ promptDraft,
+ reasoningLevel,
+ selectedProviderId,
+ serviceTier,
+ setBottomAttachmentError,
+ supportsServiceTier,
+ ],
+ );
+
const handleSend = useCallback(async () => {
const submittedDraft = currentPromptDraft;
const submittedInput = currentPromptDraftInput;
+ if (isHandoffSelection) {
+ await createHandoffThread(submittedDraft).catch(
+ ignoreToastedCreateThreadError,
+ );
+ return;
+ }
const isQueuingMessage = shouldQueueFollowUpMessage(runtimeDisplayStatus);
if (
submittedInput.length === 0 ||
@@ -845,11 +1047,13 @@ export function ThreadDetailPromptArea({
});
}
}, [
+ createHandoffThread,
createQueuedMessage,
currentPromptDraft,
currentPromptDraftInput,
followUpExecutionSelection,
isDefaultExecutionOptionsLoading,
+ isHandoffSelection,
promptDraft,
sendMessage,
setBottomAttachmentError,
@@ -858,6 +1062,27 @@ export function ThreadDetailPromptArea({
]);
const submitScheduled = useCallback(
async ({ sendAt }: { sendAt: number }) => {
+ if (isHandoffSelection) {
+ if (effectiveSelectedModel.length === 0) {
+ throw new Error("The selected model is still loading.");
+ }
+ let created = false;
+ try {
+ created = await createHandoffThread(promptDraft.getCurrent(), sendAt);
+ } catch (scheduleError) {
+ throw new Error(
+ getMutationErrorMessage({
+ error: scheduleError,
+ fallbackMessage: "Failed to create thread",
+ lifecycleOperation: "create_thread",
+ }),
+ );
+ }
+ if (!created) {
+ throw new Error("Type a message before scheduling it.");
+ }
+ return;
+ }
if (isDefaultExecutionOptionsLoading) {
throw new Error("This thread's model options are still loading.");
}
@@ -889,8 +1114,11 @@ export function ThreadDetailPromptArea({
}
},
[
+ createHandoffThread,
+ effectiveSelectedModel,
followUpExecutionSelection,
isDefaultExecutionOptionsLoading,
+ isHandoffSelection,
promptDraft,
sendMessage,
setBottomAttachmentError,
@@ -993,28 +1221,6 @@ export function ThreadDetailPromptArea({
const handleUnarchiveCurrentThread = useCallback(() => {
unarchiveThread.mutate({ id: thread.id });
}, [thread.id, unarchiveThread]);
- const sourceThreadDisplayTitle = getThreadDisplayTitle({
- id: thread.id,
- title: thread.title,
- titleFallback: thread.titleFallback,
- });
- const handleHandoffToNewThread = useCallback(() => {
- navigate(getProjectComposeRoutePath(thread.projectId), {
- state: buildThreadHandoffLocationState({
- environmentId: thread.environmentId,
- projectId: thread.projectId,
- sourceThreadId: thread.id,
- sourceThreadTitle: sourceThreadDisplayTitle,
- }),
- });
- }, [
- navigate,
- sourceThreadDisplayTitle,
- thread.environmentId,
- thread.id,
- thread.projectId,
- ]);
-
const bottomAttachmentsConfig = useMemo(
() => ({
items: currentPromptDraft.attachments,
@@ -1057,6 +1263,13 @@ export function ThreadDetailPromptArea({
onChangeMessage: promptDraft.setTextAndMentions,
onModifierSubmit: handleBottomComposerModifierSubmit,
onSubmit: handleBottomComposerSubmit,
+ ...(isHandoffSelection
+ ? {
+ submitLabel: "New thread",
+ submitIcon: "MessageSquarePlus",
+ submitTitle: "Create new thread (Enter)",
+ }
+ : {}),
compactPromptPlaceholder,
promptPlaceholder,
canModifierSubmit: canSubmitModifierShortcut,
@@ -1071,6 +1284,7 @@ export function ThreadDetailPromptArea({
handleBottomComposerModifierSubmit,
handleBottomComposerSubmit,
isFollowUpSubmitting,
+ isHandoffSelection,
promptHistoryDrafts,
promptPlaceholder,
promptDraft.setDraft,
@@ -1124,10 +1338,14 @@ export function ThreadDetailPromptArea({
]);
const bottomExecutionConfig = useMemo(
() => ({
- providerRouting: executionOptionsRouting,
+ providerRouting:
+ thread.environmentId === null
+ ? executionOptionsRouting
+ : { environmentId: thread.environmentId },
provider: {
options: providerOptions,
selectedId: selectedProviderId,
+ onChange: handleProviderChange,
hasMultiple: hasMultipleProviders,
},
model: {
@@ -1154,17 +1372,18 @@ export function ThreadDetailPromptArea({
options: reasoningOptions,
onChange: setReasoningLevel,
},
- footerAction: {
- label: "Handoff to new thread",
- onClick: handleHandoffToNewThread,
+ handoff: {
+ sourceProviderId: thread.providerId,
+ onSelect: handleHandoffSelect,
},
}),
[
effectiveSelectedModel,
executionOptionsRouting,
hasMultipleProviders,
- handleHandoffToNewThread,
+ handleHandoffSelect,
handleModelChange,
+ handleProviderChange,
isLoadingModels,
modelLoadFailed,
modelLoadError,
@@ -1181,13 +1400,21 @@ export function ThreadDetailPromptArea({
setServiceTier,
supportsServiceTier,
serviceTierFastLabel,
+ thread.environmentId,
+ thread.providerId,
],
);
const compactExecutionConfig = useMemo(() => {
- const { footerAction: _footerAction, ...executionWithoutFooterAction } =
- bottomExecutionConfig;
- return executionWithoutFooterAction;
- }, [bottomExecutionConfig]);
+ const {
+ handoff: _handoff,
+ provider: { onChange: _onProviderChange, ...lockedProvider },
+ ...lockedExecution
+ } = bottomExecutionConfig;
+ return {
+ ...lockedExecution,
+ provider: { ...lockedProvider, selectedId: thread.providerId },
+ };
+ }, [bottomExecutionConfig, thread.providerId]);
const inlineExecutionConfig = useMemo(() => {
if (!inlineEditingQueuedMessage) return null;
return {
@@ -1373,13 +1600,13 @@ export function ThreadDetailPromptArea({
onSelectHistoryEntry: setActiveComposerDraft,
permission: inlinePermissionConfig,
pluginComposerHost: queuedMessagePluginComposerHost,
- promptActions,
+ promptActions: inlinePromptActions,
promptPlaceholder,
submit: handleInlineComposerSubmit,
submitMode: { kind: "ready" },
textEffects: queuedComposerTextEffects,
threadRuntimeDisplayStatus: runtimeDisplayStatus,
- typeahead: typeaheadConfig,
+ typeahead: inlineTypeaheadConfig,
collapseResetKey: `queued-message:${queuedMessageId}`,
}),
};
@@ -1400,7 +1627,7 @@ export function ThreadDetailPromptArea({
isAttachingInlineFiles,
isUpdateQueuedMessagePending,
projectId,
- promptActions,
+ inlinePromptActions,
promptPlaceholder,
queuedComposerTextEffects,
queuedMessagePluginComposerHost,
@@ -1408,7 +1635,7 @@ export function ThreadDetailPromptArea({
runtimeDisplayStatus,
setActiveComposerDraft,
thread.id,
- typeaheadConfig,
+ inlineTypeaheadConfig,
]);
usePublishPluginComposerHost(
queuedMessageEditor
@@ -1491,7 +1718,7 @@ export function ThreadDetailPromptArea({
sentMessageEdit.updateDraft(() => nextDraft),
permission: bottomPermissionConfig,
pluginComposerHost: sentMessagePluginComposerHost,
- promptActions,
+ promptActions: inlinePromptActions,
promptPlaceholder: "Edit message",
submit: handleSentMessageEditSubmit,
submitMode: sentMessageEditSubmitMode,
@@ -1499,7 +1726,7 @@ export function ThreadDetailPromptArea({
suppressPluginComposerCustomizations: true,
textEffects: sentMessageComposerTextEffects,
threadRuntimeDisplayStatus: runtimeDisplayStatus,
- typeahead: typeaheadConfig,
+ typeahead: inlineTypeaheadConfig,
collapseResetKey: `sent-message:${operationId}`,
})}
,
@@ -1514,7 +1741,7 @@ export function ThreadDetailPromptArea({
handleSentMessageEditSubmit,
isAttachingSentMessageFiles,
projectId,
- promptActions,
+ inlinePromptActions,
runtimeDisplayStatus,
sentMessageAttachmentError,
sentMessageComposerTextEffects,
@@ -1522,7 +1749,7 @@ export function ThreadDetailPromptArea({
sentMessageEditSubmitMode,
sentMessagePluginComposerHost,
thread.id,
- typeaheadConfig,
+ inlineTypeaheadConfig,
]);
const childPendingInteractionBanners = useMemo(
() =>
@@ -1718,7 +1945,7 @@ export function ThreadDetailPromptArea({
attachments={bottomAttachmentsConfig}
stack={pendingInteractionNode ? pendingInteractionStack : promptStack}
pendingInteraction={pendingInteractionNode}
- activePromptMode={activePromptMode}
+ activePromptMode={isHandoffSelection ? null : activePromptMode}
composer={shouldHideComposer ? null : bottomComposerConfig}
pluginComposerHost={normalPluginComposerHost}
pluginComposerScope={normalPluginComposerHost.scope}
diff --git a/packages/client-core/src/prompt/thread-handoff-request.ts b/packages/client-core/src/prompt/thread-handoff-request.ts
index ac00ac8f88..eb16d01983 100644
--- a/packages/client-core/src/prompt/thread-handoff-request.ts
+++ b/packages/client-core/src/prompt/thread-handoff-request.ts
@@ -1,8 +1,12 @@
-import type { PromptTextMention } from "@bb/domain";
-import type { PromptDraftState } from "./prompt-draft.js";
-
-export const THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY =
- "threadHandoffCreateSeed";
+import type {
+ PermissionMode,
+ PromptTextMention,
+ ReasoningLevel,
+ ServiceTier,
+} from "@bb/domain";
+import type { ExistingThreadExecutionInputSources } from "@bb/server-contract";
+import type { AppCreateThreadRequest } from "../api-types.js";
+import { promptDraftToInput, type PromptDraftState } from "./prompt-draft.js";
export interface ThreadHandoffCreateSeed {
environmentId: string | null;
@@ -11,64 +15,6 @@ export interface ThreadHandoffCreateSeed {
sourceThreadTitle: string;
}
-interface ThreadHandoffLocationState {
- focusPrompt: true;
- reuseEnvironmentId?: string;
- [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: ThreadHandoffCreateSeed;
-}
-
-export function buildThreadHandoffLocationState(
- seed: ThreadHandoffCreateSeed,
-): ThreadHandoffLocationState {
- return {
- focusPrompt: true,
- ...(seed.environmentId !== null
- ? { reuseEnvironmentId: seed.environmentId }
- : {}),
- [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: seed,
- };
-}
-
-export function readThreadHandoffCreateSeedFromLocationState(
- state: unknown,
-): ThreadHandoffCreateSeed | null {
- if (!state || typeof state !== "object") return null;
- const candidate = (state as Record)[
- THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY
- ];
- if (!candidate || typeof candidate !== "object") return null;
- const value = candidate as Record;
- if (
- typeof value.projectId !== "string" ||
- value.projectId.length === 0 ||
- typeof value.sourceThreadId !== "string" ||
- value.sourceThreadId.length === 0 ||
- typeof value.sourceThreadTitle !== "string" ||
- value.sourceThreadTitle.trim().length === 0
- ) {
- return null;
- }
- if (
- value.environmentId !== undefined &&
- value.environmentId !== null &&
- typeof value.environmentId !== "string"
- ) {
- return null;
- }
-
- const environmentId =
- typeof value.environmentId === "string" && value.environmentId.length > 0
- ? value.environmentId
- : null;
-
- return {
- environmentId,
- projectId: value.projectId,
- sourceThreadId: value.sourceThreadId,
- sourceThreadTitle: value.sourceThreadTitle.trim(),
- };
-}
-
export function buildThreadHandoffPromptDraft(
seed: ThreadHandoffCreateSeed,
): PromptDraftState {
@@ -88,3 +34,129 @@ export function buildThreadHandoffPromptDraft(
return { text, mentions: [mention], attachments: [] };
}
+
+const THREAD_HANDOFF_FOLLOW_UP_SEPARATOR = "\n\n";
+
+export interface ThreadHandoffExecutionSelection {
+ providerId: string;
+ model: string;
+ reasoningLevel: ReasoningLevel;
+ serviceTier: ServiceTier | undefined;
+ supportsServiceTier: boolean;
+ permissionMode: PermissionMode;
+ executionInputSources: ExistingThreadExecutionInputSources;
+}
+
+interface BuildThreadHandoffCreateRequestArgs {
+ draft: PromptDraftState;
+ execution: ThreadHandoffExecutionSelection;
+ seed: ThreadHandoffCreateSeed;
+ sendAt?: number;
+}
+
+function threadHandoffPrefixLength(
+ seed: ThreadHandoffCreateSeed,
+ draft: PromptDraftState,
+): number | null {
+ const handoff = buildThreadHandoffPromptDraft(seed);
+ const [handoffMention] = handoff.mentions;
+ const hasHandoffMention =
+ handoffMention !== undefined &&
+ draft.mentions.some(
+ (mention) =>
+ mention.start === handoffMention.start &&
+ mention.end === handoffMention.end &&
+ mention.resource.kind === "thread" &&
+ mention.resource.threadId === seed.sourceThreadId,
+ );
+ if (!hasHandoffMention || !draft.text.startsWith(handoff.text)) {
+ return null;
+ }
+ let prefixLength = handoff.text.length;
+ if (prefixLength < draft.text.length && draft.text[prefixLength] !== "\n") {
+ return null;
+ }
+ while (draft.text[prefixLength] === "\n") {
+ prefixLength += 1;
+ }
+ return prefixLength;
+}
+
+export function buildThreadHandoffFollowUpDraft(
+ seed: ThreadHandoffCreateSeed,
+ draft: PromptDraftState,
+): PromptDraftState {
+ if (threadHandoffPrefixLength(seed, draft) !== null) {
+ return draft;
+ }
+ const handoff = buildThreadHandoffPromptDraft(seed);
+ const offset =
+ handoff.text.length + THREAD_HANDOFF_FOLLOW_UP_SEPARATOR.length;
+ return {
+ text: `${handoff.text}${THREAD_HANDOFF_FOLLOW_UP_SEPARATOR}${draft.text}`,
+ mentions: [
+ ...handoff.mentions,
+ ...draft.mentions.map((mention) => ({
+ ...mention,
+ start: mention.start + offset,
+ end: mention.end + offset,
+ })),
+ ],
+ attachments: draft.attachments,
+ };
+}
+
+export function stripThreadHandoffPrefix(
+ seed: ThreadHandoffCreateSeed,
+ draft: PromptDraftState,
+): PromptDraftState | null {
+ const prefixLength = threadHandoffPrefixLength(seed, draft);
+ if (prefixLength === null) {
+ return null;
+ }
+ return {
+ text: draft.text.slice(prefixLength),
+ mentions: draft.mentions
+ .filter((mention) => mention.start >= prefixLength)
+ .map((mention) => ({
+ ...mention,
+ start: mention.start - prefixLength,
+ end: mention.end - prefixLength,
+ })),
+ attachments: draft.attachments,
+ };
+}
+
+export function buildThreadHandoffCreateRequest({
+ draft,
+ execution,
+ seed,
+ sendAt,
+}: BuildThreadHandoffCreateRequestArgs): AppCreateThreadRequest | null {
+ const input = promptDraftToInput(draft);
+ if (execution.model.length === 0 || input.length === 0) {
+ return null;
+ }
+
+ return {
+ environment:
+ seed.environmentId === null
+ ? { type: "project-default" }
+ : { type: "reuse", environmentId: seed.environmentId },
+ executionInputSources: {
+ providerId: "explicit",
+ ...execution.executionInputSources,
+ },
+ input,
+ model: execution.model,
+ permissionMode: execution.permissionMode,
+ projectId: seed.projectId,
+ providerId: execution.providerId,
+ reasoningLevel: execution.reasoningLevel,
+ ...(execution.supportsServiceTier && execution.serviceTier
+ ? { serviceTier: execution.serviceTier }
+ : {}),
+ ...(sendAt === undefined ? {} : { sendAt }),
+ startedOnBehalfOf: null,
+ };
+}
diff --git a/packages/client-core/test/thread-handoff-request.test.ts b/packages/client-core/test/thread-handoff-request.test.ts
index 93860a8508..dfac5ed96d 100644
--- a/packages/client-core/test/thread-handoff-request.test.ts
+++ b/packages/client-core/test/thread-handoff-request.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import {
- buildThreadHandoffLocationState,
- buildThreadHandoffPromptDraft,
- readThreadHandoffCreateSeedFromLocationState,
- THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY,
+ buildThreadHandoffCreateRequest,
+ buildThreadHandoffFollowUpDraft,
+ stripThreadHandoffPrefix,
type ThreadHandoffCreateSeed,
+ type ThreadHandoffExecutionSelection,
} from "../src/prompt/thread-handoff-request.js";
const SEED: ThreadHandoffCreateSeed = {
@@ -14,53 +14,209 @@ const SEED: ThreadHandoffCreateSeed = {
sourceThreadTitle: "Source thread",
};
-describe("thread handoff request", () => {
- it("builds location state that focuses compose and reuses the source environment", () => {
- expect(buildThreadHandoffLocationState(SEED)).toEqual({
- focusPrompt: true,
- reuseEnvironmentId: "env_source",
- [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: SEED,
+const EXECUTION: ThreadHandoffExecutionSelection = {
+ providerId: "claude-code",
+ model: "claude-opus-5",
+ reasoningLevel: "high",
+ serviceTier: "fast",
+ supportsServiceTier: true,
+ permissionMode: "auto",
+ executionInputSources: { model: "explicit", reasoningLevel: "explicit" },
+};
+
+const SOURCE_MENTION = {
+ start: 14,
+ end: 32,
+ resource: {
+ kind: "thread" as const,
+ projectId: "proj_source",
+ threadId: "thr_source",
+ label: "Source thread",
+ },
+};
+
+describe("buildThreadHandoffFollowUpDraft", () => {
+ it("keeps follow-up mentions anchored after the source thread mention", () => {
+ const draft = buildThreadHandoffFollowUpDraft(SEED, {
+ text: "Also see @thread:thr_other next",
+ mentions: [
+ {
+ start: 9,
+ end: 26,
+ resource: {
+ kind: "thread",
+ projectId: "proj_source",
+ threadId: "thr_other",
+ label: "Other thread",
+ },
+ },
+ ],
+ attachments: [],
});
+
+ expect(draft.text).toBe(
+ "Continue from @thread:thr_source\n\nAlso see @thread:thr_other next",
+ );
+ expect(draft.mentions).toHaveLength(2);
+ expect(draft.mentions[0]).toEqual(SOURCE_MENTION);
+ expect(
+ draft.text.slice(draft.mentions[1]!.start, draft.mentions[1]!.end),
+ ).toBe("@thread:thr_other");
});
- it("reads a valid handoff seed from location state", () => {
+ it("leaves a draft alone when it already starts with the source reference", () => {
+ const draft = {
+ text: "Continue from @thread:thr_source\n\nKeep going",
+ mentions: [SOURCE_MENTION],
+ attachments: [],
+ };
+
+ expect(buildThreadHandoffFollowUpDraft(SEED, draft)).toBe(draft);
+ });
+});
+
+describe("stripThreadHandoffPrefix", () => {
+ it("removes the inserted reference and re-anchors later mentions", () => {
expect(
- readThreadHandoffCreateSeedFromLocationState({
- [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: {
- ...SEED,
- sourceThreadTitle: " Source thread ",
+ stripThreadHandoffPrefix(SEED, {
+ text: "Continue from @thread:thr_source\n\nSee @thread:thr_other",
+ mentions: [
+ SOURCE_MENTION,
+ {
+ start: 38,
+ end: 55,
+ resource: {
+ kind: "thread",
+ projectId: "proj_source",
+ threadId: "thr_other",
+ label: "Other thread",
+ },
+ },
+ ],
+ attachments: [],
+ }),
+ ).toEqual({
+ text: "See @thread:thr_other",
+ mentions: [
+ {
+ start: 4,
+ end: 21,
+ resource: {
+ kind: "thread",
+ projectId: "proj_source",
+ threadId: "thr_other",
+ label: "Other thread",
+ },
},
+ ],
+ attachments: [],
+ });
+ expect(
+ stripThreadHandoffPrefix(SEED, {
+ text: "Continue from @thread:thr_source",
+ mentions: [SOURCE_MENTION],
+ attachments: [],
}),
- ).toEqual(SEED);
+ ).toEqual({ text: "", mentions: [], attachments: [] });
});
- it("builds a prompt draft with a rich mention to the source thread", () => {
- const draft = buildThreadHandoffPromptDraft(SEED);
+ it("tolerates the editor collapsing the blank line after the reference", () => {
+ expect(
+ stripThreadHandoffPrefix(SEED, {
+ text: "Continue from @thread:thr_source\nKeep going\n",
+ mentions: [SOURCE_MENTION],
+ attachments: [],
+ }),
+ ).toEqual({ text: "Keep going\n", mentions: [], attachments: [] });
+ expect(
+ buildThreadHandoffFollowUpDraft(SEED, {
+ text: "Continue from @thread:thr_source\nKeep going",
+ mentions: [SOURCE_MENTION],
+ attachments: [],
+ }).text,
+ ).toBe("Continue from @thread:thr_source\nKeep going");
+ });
- expect(draft.text).toBe("Continue from @thread:thr_source");
- expect(draft.attachments).toEqual([]);
- expect(draft.mentions).toEqual([
- {
- start: "Continue from ".length,
- end: "Continue from @thread:thr_source".length,
- resource: {
- kind: "thread",
- projectId: "proj_source",
- threadId: "thr_source",
- label: "Source thread",
- },
+ it("returns null once the user has changed the reference", () => {
+ expect(
+ stripThreadHandoffPrefix(SEED, {
+ text: "Continue from @thread:thr_source please",
+ mentions: [SOURCE_MENTION],
+ attachments: [],
+ }),
+ ).toBeNull();
+ expect(
+ stripThreadHandoffPrefix(SEED, {
+ text: "Continue from @thread:thr_source\n\nKeep going",
+ mentions: [],
+ attachments: [],
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("buildThreadHandoffCreateRequest", () => {
+ it("creates a thread on the selected provider from the draft as typed", () => {
+ const request = buildThreadHandoffCreateRequest({
+ draft: {
+ text: "Continue from @thread:thr_source\n\nRefactor the tests",
+ mentions: [SOURCE_MENTION],
+ attachments: [],
+ },
+ execution: EXECUTION,
+ seed: SEED,
+ });
+
+ expect(request).toEqual({
+ environment: { type: "reuse", environmentId: "env_source" },
+ executionInputSources: {
+ providerId: "explicit",
+ model: "explicit",
+ reasoningLevel: "explicit",
},
- ]);
+ input: [
+ {
+ type: "text",
+ text: "Continue from @thread:thr_source\n\nRefactor the tests",
+ mentions: [SOURCE_MENTION],
+ },
+ ],
+ model: "claude-opus-5",
+ permissionMode: "auto",
+ projectId: "proj_source",
+ providerId: "claude-code",
+ reasoningLevel: "high",
+ serviceTier: "fast",
+ startedOnBehalfOf: null,
+ });
+ });
+
+ it("falls back to the project default environment and drops unsupported service tiers", () => {
+ const request = buildThreadHandoffCreateRequest({
+ draft: { text: "Keep going", mentions: [], attachments: [] },
+ execution: { ...EXECUTION, supportsServiceTier: false },
+ seed: { ...SEED, environmentId: null },
+ sendAt: 1_700_000_000_000,
+ });
+
+ expect(request?.environment).toEqual({ type: "project-default" });
+ expect(request).not.toHaveProperty("serviceTier");
+ expect(request?.sendAt).toBe(1_700_000_000_000);
});
- it("returns null for unusable handoff state", () => {
- expect(readThreadHandoffCreateSeedFromLocationState(null)).toBeNull();
+ it("returns null without follow-up input or a resolved model", () => {
expect(
- readThreadHandoffCreateSeedFromLocationState({
- [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: {
- ...SEED,
- sourceThreadId: "",
- },
+ buildThreadHandoffCreateRequest({
+ draft: { text: " ", mentions: [], attachments: [] },
+ execution: EXECUTION,
+ seed: SEED,
+ }),
+ ).toBeNull();
+ expect(
+ buildThreadHandoffCreateRequest({
+ draft: { text: "Keep going", mentions: [], attachments: [] },
+ execution: { ...EXECUTION, model: "" },
+ seed: SEED,
}),
).toBeNull();
});