diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 485cc79ba9..b52e87eafd 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -626,6 +626,8 @@ export default function TaskDetailScreen() { } onOpenTask={handleOpenTask} onSendPermissionResponse={handleSendPermissionResponse} + model={composerModel} + onModelChange={handleModelChange} optimisticUserMessage={ optimisticPrompt ? { diff --git a/apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx b/apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx index f22275268d..a745501615 100644 --- a/apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx +++ b/apps/mobile/src/features/tasks/components/PlanApprovalCard.test.tsx @@ -2,6 +2,8 @@ import { createElement } from "react"; import { TextInput } from "react-native"; import { act, create } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; +import { modelLabel } from "../composer/options"; +import type { CloudPendingPermissionRequest } from "../types"; import { PlanApprovalCard } from "./PlanApprovalCard"; vi.mock("phosphor-react-native", () => ({ @@ -11,6 +13,16 @@ vi.mock("phosphor-react-native", () => ({ createElement("ChatCircle", props), CheckCircle: (props: Record) => createElement("CheckCircle", props), + Robot: (props: Record) => createElement("Robot", props), +})); + +vi.mock("../composer/Pill", () => ({ + Pill: (props: Record) => createElement("Pill", props), +})); + +vi.mock("../composer/SelectSheet", () => ({ + SelectSheet: (props: Record) => + createElement("SelectSheet", props), })); vi.mock("@/lib/theme", () => ({ @@ -196,4 +208,78 @@ describe("PlanApprovalCard", () => { displayText: "Keep the rollback plan tighter.", }); }); + + const pendingPermission: CloudPendingPermissionRequest = { + requestId: "request-model", + toolCall: { + toolCallId: "tool-model", + title: "Ready to code?", + kind: "switch_mode", + rawInput: { plan: "Do the thing" }, + }, + options: [{ kind: "allow_once", optionId: "default", name: "Approve" }], + }; + + it("shows the model pill and swaps the model inline before approval", () => { + const onModelChange = vi.fn(); + let renderer: ReturnType | null = null; + + act(() => { + renderer = create( + createElement(PlanApprovalCard, { + toolData: { toolCallId: "tool-model", status: "pending" }, + permission: pendingPermission, + model: "claude-opus-4-8", + onModelChange, + }), + ); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + const pill = renderer.root.findByType("Pill"); + expect(pill.props.label).toBe(modelLabel("claude-opus-4-8")); + + const sheet = renderer.root.findByType("SelectSheet"); + act(() => { + sheet.props.onChange("claude-sonnet-5"); + }); + + expect(onModelChange).toHaveBeenCalledWith("claude-sonnet-5"); + }); + + it.each([ + { + name: "when no onModelChange is provided", + props: { + toolData: { toolCallId: "tool-model", status: "pending" as const }, + permission: pendingPermission, + model: "claude-opus-4-8", + }, + }, + { + name: "once the plan is resolved", + props: { + toolData: { toolCallId: "tool-model", status: "completed" as const }, + permission: pendingPermission, + model: "claude-opus-4-8", + onModelChange: vi.fn(), + }, + }, + ])("hides the model control $name", ({ props }) => { + let renderer: ReturnType | null = null; + + act(() => { + renderer = create(createElement(PlanApprovalCard, props)); + }); + + if (!renderer) { + throw new Error("Renderer not created"); + } + + expect(renderer.root.findAllByType("Pill")).toHaveLength(0); + expect(renderer.root.findAllByType("SelectSheet")).toHaveLength(0); + }); }); diff --git a/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx index 6781c7cdff..e40c5c6606 100644 --- a/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx +++ b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx @@ -2,11 +2,15 @@ import { ArrowsClockwise, ChatCircle, CheckCircle, + Robot, } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { MarkdownText, type ToolStatus } from "@/features/chat"; import { useThemeColors } from "@/lib/theme"; +import { MODELS, modelLabel } from "../composer/options"; +import { Pill } from "../composer/Pill"; +import { SelectSheet } from "../composer/SelectSheet"; import type { CloudPendingPermissionRequest } from "../types"; interface ToolData { @@ -26,6 +30,9 @@ interface PlanApprovalCardProps { toolData: ToolData; permission?: CloudPendingPermissionRequest; onSendPermissionResponse?: (args: PermissionResponseArgs) => void; + /** Current model for the task; enables the inline model swap when set. */ + model?: string; + onModelChange?: (model: string) => void; } function optionMeta(option: CloudPendingPermissionRequest["options"][number]) { @@ -82,12 +89,15 @@ export function PlanApprovalCard({ toolData, permission, onSendPermissionResponse, + model, + onModelChange, }: PlanApprovalCardProps) { const themeColors = useThemeColors(); const [selectedCustomOptionId, setSelectedCustomOptionId] = useState< string | null >(null); const [customInput, setCustomInput] = useState(""); + const [modelSheetOpen, setModelSheetOpen] = useState(false); const response = permission?.response; const planText = useMemo(() => extractPlanText(permission), [permission]); @@ -102,6 +112,7 @@ export function PlanApprovalCard({ !!response || toolData.status === "completed" || toolData.status === "error"; + const canSwapModel = !isResolved && !!onModelChange && !!model; if (!permission) { return null; @@ -149,6 +160,17 @@ export function PlanApprovalCard({ + {canSwapModel && model && ( + + Model + } + label={modelLabel(model)} + onPress={() => setModelSheetOpen(true)} + /> + + )} + {planText && ( @@ -267,6 +289,22 @@ export function PlanApprovalCard({ })} )} + + {canSwapModel && model && ( + setModelSheetOpen(false)} + options={MODELS.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + icon: , + }))} + /> + )} ); } diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index fb4c23ed13..3392bf31c5 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -60,6 +60,10 @@ interface TaskSessionViewProps { onRetry?: () => void; onOpenTask?: (taskId: string) => void; onSendPermissionResponse?: (args: PermissionResponseArgs) => void; + /** Current task model + setter, forwarded to the plan-approval card so the + * user can swap the model inline before approving. */ + model?: string; + onModelChange?: (model: string) => void; contentContainerStyle?: object; // Renders a user message at the bottom of the thread before the SSE echo // arrives — for the gap between submit and the live session catching up. @@ -805,6 +809,8 @@ export function TaskSessionView({ onRetry, onOpenTask, onSendPermissionResponse, + model, + onModelChange, contentContainerStyle, optimisticUserMessage, }: TaskSessionViewProps) { @@ -963,6 +969,8 @@ export function TaskSessionView({ toolData={item.toolData} permission={pendingPermissions?.[item.toolData.toolCallId]} onSendPermissionResponse={onSendPermissionResponse} + model={model} + onModelChange={onModelChange} /> ); } @@ -994,7 +1002,13 @@ export function TaskSessionView({ return null; } }, - [onOpenTask, onSendPermissionResponse, pendingPermissions], + [ + onOpenTask, + onSendPermissionResponse, + pendingPermissions, + model, + onModelChange, + ], ); return (