Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 8 additions & 5 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,10 +464,7 @@ export function NewTaskDraftScreen(props: {
selectedEnvironmentServerConfig?.providers ?? [],
selectedEnvironmentServerConfig?.usageLimitSources ?? [],
);
const composerWorkspaceCwd =
(flow.workspaceMode === "worktree"
? selectedProject?.workspaceRoot
: (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null;
const composerWorkspaceCwd = flow.modelCwd;
const stripAttachments = composerStripAttachments(flow.attachments);
const composerMenu = useComposerCommandMenu({
draftMessage: flow.prompt,
Expand Down Expand Up @@ -1183,13 +1180,19 @@ export function NewTaskDraftScreen(props: {
}
const draft = getComposerDraftSnapshot(draftKey);
if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return;
const workspaceSelection = flow.canChooseWorkspace ? draft.workspaceSelection : undefined;
const workspaceMode = workspaceSelection?.mode ?? flow.workspaceMode;
const cwd =
workspaceMode === "worktree"
? selectedProject.workspaceRoot
: (workspaceSelection?.worktreePath ?? selectedProject.workspaceRoot);
const modelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
selectedProject.id,
cwd,
) ?? flow.selectedModel;
const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode;
const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName;
const initialMessageText = draft.text.trim();

Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,8 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
currentModelSelection,
lockedProviderInstanceId,
props.selectedThread.projectId,
false,
props.projectCwd,
);
const threadProviderGroups = groupByProvider(modelOptions);
const currentModelOption =
Expand All @@ -599,6 +601,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
ownerId: settingsOwnerId,
environmentId: props.environmentId,
projectId: props.selectedThread.projectId,
cwd: props.projectCwd,
providerInstanceId: currentModelSelection.instanceId,
...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}),
providerGroups: threadProviderGroups,
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ type ThreadSettingsSubmenuPage =
type ThreadSettingsSessionProps = {
readonly environmentId: EnvironmentId | null;
readonly projectId: ProjectId | null;
readonly cwd?: string | null;
readonly providerInstanceId?: ProviderInstanceId;
readonly lockedProviderInstanceId?: ProviderInstanceId;
readonly providerGroups: ReadonlyArray<ProviderGroup>;
Expand Down Expand Up @@ -359,6 +360,7 @@ function ThreadSettingsSessionProvider(
props.lockedProviderInstanceId,
props.projectId,
true,
props.cwd,
),
)
: props.providerGroups;
Expand Down Expand Up @@ -1479,6 +1481,7 @@ export function NewTaskThreadSettingsRouteScreen() {
<ThreadSettingsPickerScreen
environmentId={flow.selectedEnvironmentId}
projectId={flow.selectedProject?.id ?? null}
cwd={flow.modelCwd}
{...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})}
providerGroups={flow.providerGroups}
selectedModel={flow.selectedModel}
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ type NewTaskFlowContextValue = {
readonly selectedProjectKey: string | null;
readonly selectedModelKey: string | null;
readonly workspaceMode: WorkspaceMode;
readonly modelCwd: string | null;
readonly canChooseWorkspace: boolean;
readonly selectedBranchName: string | null;
readonly selectedWorktreePath: string | null;
Expand Down Expand Up @@ -508,12 +509,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
: projectSettings.settings.defaultRuntimeMode;
const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode;

const modelCwd =
(workspaceMode === "worktree"
? selectedProject?.workspaceRoot
: ((canChooseWorkspace ? selectedWorktreePath : null) ?? selectedProject?.workspaceRoot)) ||
null;
// Antigravity keeps unavailable selections so sign-out or a catalog change
// cannot switch the user's model.
const draftModelSelection = resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
selectedProjectDraft.modelSelection ?? null,
selectedProject?.id ?? null,
modelCwd,
);
const projectDefaultModelSelection = resolveDefaultableModelSelection(
selectedEnvironmentServerConfig,
Expand All @@ -531,6 +538,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
undefined,
selectedProject?.id ?? null,
false,
modelCwd,
);

const selectedModel = resolveNewTaskModelSelection({
Expand Down Expand Up @@ -948,17 +957,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
}
const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
const text = draft.text.trim();
const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined;
const mode = workspaceSelection?.mode ?? workspaceMode;
const cwd =
mode === "worktree"
? selectedProject.workspaceRoot
: (workspaceSelection?.worktreePath ?? selectedProject.workspaceRoot);
const draftModelSelection =
resolveSelectableModelSelection(
selectedEnvironmentServerConfig,
draft.modelSelection ?? null,
selectedProject.id,
cwd,
) ?? selectedModel;
if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) {
return null;
}
const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined;
const mode = workspaceSelection?.mode ?? workspaceMode;
// When the selection is the stand-in built from the queued snapshot,
// persist the original (possibly absent) snapshot values — the
// stand-in's placeholder title/workspaceRoot must never be written back
Expand Down Expand Up @@ -1117,6 +1131,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedProjectKey,
selectedModelKey,
workspaceMode,
modelCwd,
canChooseWorkspace,
selectedBranchName,
selectedWorktreePath,
Expand Down
99 changes: 99 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,105 @@ describe("mobile model options", () => {
expect(resolveDefaultableModelSelection(null, legacy, null)).toBe(legacy);
});

it("normalizes a draft for its destination workspace without losing reasoning or project visibility", () => {
const instanceId = ProviderInstanceId.make("opencode_work");
const projectId = ProjectId.make("project-a");
const agent = (name: string) => ({
id: "agent",
label: "Agent",
type: "select" as const,
options: [
{ id: "build", label: "Build", isDefault: true },
{ id: name, label: name },
],
currentValue: "build",
});
const reasoning = {
id: "variant",
label: "Reasoning",
type: "select" as const,
options: [{ id: "high", label: "High" }],
};
const config = {
providers: [
{
instanceId,
driver: "opencode",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: ["model", "hidden"].map((slug) => ({
slug,
name: slug,
isCustom: false,
capabilities: { optionDescriptors: [reasoning, agent("global")] },
})),
workspaceSnapshots: ["reviewer", "writer"].map((name, index) => ({
cwd: `/work/${index}`,
checkedAt: "2026-01-01T00:00:00.000Z",
skills: [],
slashCommands: [],
optionDescriptors: [agent(name)],
})),
},
],
settings: {
...DEFAULT_SERVER_SETTINGS,
providerInstances: {
[instanceId]: { driver: ProviderDriverKind.make("opencode"), enabled: false },
},
projectSettingsOverrides: {
[projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
},
providerModelPreferences: { [instanceId]: { hiddenModels: ["hidden"], modelOrder: [] } },
},
} as unknown as ServerConfig;
const original = structuredClone(config);
const draft = {
instanceId,
model: "model",
options: [
{ id: "variant", value: "high" },
{ id: "agent", value: "reviewer" },
],
};
for (const cwd of ["/work/0", "/work/1", "/work/0"]) {
const options = buildModelOptions(config, draft, instanceId, projectId, false, cwd);
expect(options.map((option) => option.selection.model)).toEqual(["model"]);
expect
.soft(options[0]?.capabilities?.optionDescriptors)
.toEqual([reasoning, agent(cwd === "/work/0" ? "reviewer" : "writer")]);
const expectedSelection = {
...draft,
options:
cwd === "/work/0"
? draft.options
: [
{ id: "variant", value: "high" },
{ id: "agent", value: "build" },
],
};
expect.soft(options[0]?.selection).toEqual(expectedSelection);
expect
.soft(
resolveNewTaskModelSelection({
draftSelection: draft,
projectDefaultSelection: null,
stickySelection: null,
modelOptions: options,
}),
)
.toEqual(expectedSelection);
}
expect(
buildModelOptions(config, null, instanceId, projectId, true, "/work/1").map(
(option) => option.selection.model,
),
).toEqual(["model", "hidden"]);
expect(buildModelOptions(config, null, instanceId, null, true, "/work/1")).toEqual([]);
expect(config).toEqual(original);
});

it("resolves new tasks from draft, project, sticky, then provider defaults", () => {
const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" };
const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" };
Expand Down
39 changes: 30 additions & 9 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveProviderForCwd } from "@t3tools/client-runtime/providerSkills";
import type { MenuAction } from "@react-native-menu/menu";
import type {
ModelCapabilities,
Expand Down Expand Up @@ -177,6 +178,7 @@ export function resolveSelectableModelSelection(
config: T3ServerConfig | null | undefined,
selection: ModelSelection | null,
projectId: ProjectId | null,
cwd?: string | null,
): ModelSelection | null {
if (!selection || !config) {
return selection;
Expand All @@ -189,12 +191,21 @@ export function resolveSelectableModelSelection(
if (driver === "antigravity") {
return isProjectDisabledOverride(config, selection.instanceId, projectId) ? null : selection;
}
return provider &&
isProviderEnabledForProject(config, provider, projectId) &&
provider.installed &&
provider.auth.status !== "unauthenticated"
if (
!provider ||
!isProviderEnabledForProject(config, provider, projectId) ||
!provider.installed ||
provider.auth.status === "unauthenticated"
) {
return null;
}
return cwd === undefined
? selection
: null;
: normalizeSelectionOptions(
selection,
resolveProviderForCwd(provider, cwd).models.find((model) => model.slug === selection.model)
?.capabilities ?? null,
);
}

export function resolveDefaultableModelSelection(
Expand All @@ -217,10 +228,18 @@ export function resolveNewTaskModelSelection(input: {
readonly stickySelection: ModelSelection | null;
readonly modelOptions: ReadonlyArray<ModelOption>;
}): ModelSelection | null {
const selection = input.draftSelection ?? input.projectDefaultSelection ?? input.stickySelection;
if (selection) {
const option = input.modelOptions.find(
(option) =>
option.selection.instanceId === selection.instanceId &&
option.selection.model === selection.model,
);
return option && !option.isUnavailable && option.providerDriver !== "antigravity"
? option.selection
: selection;
}
return (
input.draftSelection ??
input.projectDefaultSelection ??
input.stickySelection ??
input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ??
input.modelOptions.find((option) => !option.isUnavailable)?.selection ??
null
Expand All @@ -233,10 +252,12 @@ export function buildModelOptions(
providerInstanceId: ModelSelection["instanceId"] | undefined,
projectId: ProjectId | null,
includeHidden = false,
cwd?: string | null,
): ReadonlyArray<ModelOption> {
const options = new Map<string, ModelOption>();

for (const provider of config?.providers ?? []) {
for (const snapshot of config?.providers ?? []) {
const provider = resolveProviderForCwd(snapshot, cwd);
if (
(providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
!isProviderEnabledForProject(config, provider, projectId) ||
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ vi.mock("./use-thread-selection", () => ({
}),
}));
vi.mock("./use-thread-detail", () => ({
useSelectedThreadWorktreePath: () => null,
useSelectedThreadProjection: () => null,
useSelectedThreadVisibleTurnItems: () => [],
useSelectedThreadPendingRequests: () => state.pendingRequests,
Expand Down Expand Up @@ -144,7 +145,10 @@ vi.mock("../lib/commandMetadata", () => ({
}),
}));
vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() }));
vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false }));
vi.mock(import("../lib/modelOptions"), async (importOriginal) => ({
...(await importOriginal()),
isModelSelectionUnavailable: () => false,
}));
vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() }));

import { useThreadComposerState } from "./use-thread-composer-state";
Expand Down
Loading
Loading