Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,8 @@ export default function TaskDetailScreen() {
}
onOpenTask={handleOpenTask}
onSendPermissionResponse={handleSendPermissionResponse}
model={composerModel}
onModelChange={handleModelChange}
optimisticUserMessage={
optimisticPrompt
? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -11,6 +13,16 @@ vi.mock("phosphor-react-native", () => ({
createElement("ChatCircle", props),
CheckCircle: (props: Record<string, unknown>) =>
createElement("CheckCircle", props),
Robot: (props: Record<string, unknown>) => createElement("Robot", props),
}));

vi.mock("../composer/Pill", () => ({
Pill: (props: Record<string, unknown>) => createElement("Pill", props),
}));

vi.mock("../composer/SelectSheet", () => ({
SelectSheet: (props: Record<string, unknown>) =>
createElement("SelectSheet", props),
}));

vi.mock("@/lib/theme", () => ({
Expand Down Expand Up @@ -196,4 +208,88 @@ 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<typeof create> | 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("hides the model control when no onModelChange is provided", () => {
let renderer: ReturnType<typeof create> | null = null;

act(() => {
renderer = create(
createElement(PlanApprovalCard, {
toolData: { toolCallId: "tool-model", status: "pending" },
permission: pendingPermission,
model: "claude-opus-4-8",
}),
);
});

if (!renderer) {
throw new Error("Renderer not created");
}

expect(renderer.root.findAllByType("Pill")).toHaveLength(0);
expect(renderer.root.findAllByType("SelectSheet")).toHaveLength(0);
});

it("hides the model control once the plan is resolved", () => {
let renderer: ReturnType<typeof create> | null = null;

act(() => {
renderer = create(
createElement(PlanApprovalCard, {
toolData: { toolCallId: "tool-model", status: "completed" },
permission: pendingPermission,
model: "claude-opus-4-8",
onModelChange: vi.fn(),
}),
);
});

if (!renderer) {
throw new Error("Renderer not created");
}

expect(renderer.root.findAllByType("Pill")).toHaveLength(0);
expect(renderer.root.findAllByType("SelectSheet")).toHaveLength(0);
});
});
Comment thread
Gilbert09 marked this conversation as resolved.
38 changes: 38 additions & 0 deletions apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]) {
Expand Down Expand Up @@ -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]);
Expand All @@ -102,6 +112,7 @@ export function PlanApprovalCard({
!!response ||
toolData.status === "completed" ||
toolData.status === "error";
const canSwapModel = !isResolved && !!onModelChange && !!model;

if (!permission) {
return null;
Expand Down Expand Up @@ -149,6 +160,17 @@ export function PlanApprovalCard({
</Text>
</View>

{canSwapModel && model && (
<View className="flex-row items-center gap-2 px-3 pb-2">
<Text className="font-mono text-[12px] text-gray-11">Model</Text>
<Pill
icon={<Robot size={14} color={themeColors.gray[11]} />}
label={modelLabel(model)}
onPress={() => setModelSheetOpen(true)}
/>
</View>
)}

{planText && (
<View className="px-3 pb-3">
<View className="overflow-hidden rounded-lg border border-accent-6 bg-accent-3/40">
Expand Down Expand Up @@ -267,6 +289,22 @@ export function PlanApprovalCard({
})}
</View>
)}

{canSwapModel && model && (
<SelectSheet
open={modelSheetOpen}
title="Model"
value={model}
onChange={onModelChange}
onClose={() => setModelSheetOpen(false)}
options={MODELS.map((m) => ({
value: m.value,
label: m.label,
description: m.description,
icon: <Robot size={16} color={themeColors.gray[11]} />,
}))}
/>
)}
</View>
);
}
16 changes: 15 additions & 1 deletion apps/mobile/src/features/tasks/components/TaskSessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -805,6 +809,8 @@ export function TaskSessionView({
onRetry,
onOpenTask,
onSendPermissionResponse,
model,
onModelChange,
contentContainerStyle,
optimisticUserMessage,
}: TaskSessionViewProps) {
Expand Down Expand Up @@ -963,6 +969,8 @@ export function TaskSessionView({
toolData={item.toolData}
permission={pendingPermissions?.[item.toolData.toolCallId]}
onSendPermissionResponse={onSendPermissionResponse}
model={model}
onModelChange={onModelChange}
/>
);
}
Expand Down Expand Up @@ -994,7 +1002,13 @@ export function TaskSessionView({
return null;
}
},
[onOpenTask, onSendPermissionResponse, pendingPermissions],
[
onOpenTask,
onSendPermissionResponse,
pendingPermissions,
model,
onModelChange,
],
);

return (
Expand Down
Loading