Skip to content
Merged
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
41 changes: 33 additions & 8 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,15 @@ import {
waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import { resolveSelectableModelSelection } from "../../lib/modelOptions";
import {
canSendToModelSelection,
modelSelectionDisplayName,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import {
getProviderModelAdmissionAvailability,
shouldRefreshProviderModelCatalog,
} from "../../lib/providerModelSelection";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
Expand Down Expand Up @@ -183,11 +191,19 @@ export function NewTaskDraftScreen(props: {
connectedEnvironments.find(
(environment) => environment.environmentId === selectedProject.environmentId,
)?.connectionState === "connected";
const providerAdmissionReason = getProviderAdmissionUnavailableReason({
provider: flow.selectedProviderStatus,
instanceId: flow.selectedModel ? String(flow.selectedModel.instanceId) : undefined,
providerSnapshotKnown: selectedEnvironmentServerConfig != null,
});
const providerAdmissionReason =
flow.selectedModel &&
!shouldRefreshProviderModelCatalog(flow.selectedProviderStatus, flow.selectedModel)
? getProviderModelAdmissionAvailability({
provider: flow.selectedProviderStatus,
selection: flow.selectedModel,
providerSnapshotKnown: selectedEnvironmentServerConfig != null,
}).reason
: getProviderAdmissionUnavailableReason({
provider: flow.selectedProviderStatus,
instanceId: flow.selectedModel ? String(flow.selectedModel.instanceId) : undefined,
providerSnapshotKnown: selectedEnvironmentServerConfig != null,
});
const providerUnavailable =
providerAdmissionReason === null
? null
Expand Down Expand Up @@ -993,6 +1009,7 @@ export function NewTaskDraftScreen(props: {
if (
attachmentBlockReason !== null ||
!modelSelection ||
!canSendToModelSelection(selectedEnvironmentServerConfig, modelSelection) ||
initialMessageText.length === 0 ||
flow.submitting ||
(workspaceMode === "worktree" && !selectedBranchName)
Expand Down Expand Up @@ -1440,11 +1457,19 @@ export function NewTaskDraftScreen(props: {
emphasized
iconNode={
<ProviderIcon
provider={flow.selectedModelOption?.providerDriver}
provider={
flow.selectedModelOption?.providerDriver ??
flow.selectedProviderStatus?.driver
}
size={16}
/>
}
label={flow.selectedModelOption?.label ?? "Choose model"}
label={
flow.selectedModelOption?.label ??
(flow.selectedModel
? modelSelectionDisplayName(flow.selectedModel)
: "Choose model")
}
maxWidth="100%"
onPress={settingsSheetPresentation.open}
/>
Expand Down
23 changes: 17 additions & 6 deletions apps/mobile/src/features/threads/ThreadComposer.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
import type { ModelOption } from "../../lib/modelOptions";
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability";
import {
getProviderModelAdmissionAvailability,
resolveProviderCatalogModelSelection,
shouldRefreshProviderModelCatalog,
} from "../../lib/providerModelSelection";
import { resolveProviderContinuationTransition } from "@t3tools/client-runtime/providerContinuation";
import type {
ModelSelection,
Expand Down Expand Up @@ -44,14 +49,18 @@ export function resolveThreadComposerAuthority(input: {
null)
: selectedProvider;
const providerAdmissionReason = transition.compatible
? getProviderAdmissionUnavailableReason({
provider,
instanceId: String(instanceId),
providerSnapshotKnown: input.serverConfig !== null && input.serverConfig !== undefined,
})
? shouldRefreshProviderModelCatalog(provider, input.modelSelection)
? getProviderAdmissionUnavailableReason({ provider })
: getProviderModelAdmissionAvailability({
provider,
selection: input.modelSelection,
providerSnapshotKnown: input.serverConfig !== null && input.serverConfig !== undefined,
}).reason
: transition.reason;
return {
modelSelection: providerBindingMismatch ? null : input.modelSelection,
modelSelection: providerBindingMismatch
? null
: resolveProviderCatalogModelSelection(provider, input.modelSelection),
provider,
providerAdmissionAvailable: providerAdmissionReason === null,
providerAdmissionReason,
Expand Down Expand Up @@ -107,6 +116,8 @@ export function getThreadComposerModelChangeDisabledReason(input: {
});
if (!transition.compatible) return transition.reason;
}
if (input.option.isUnavailable)
return input.option.unavailableReason ?? "This model is no longer available.";
const isCurrent =
input.option.selection.instanceId === input.currentModelSelection.instanceId &&
input.option.selection.model === input.currentModelSelection.model;
Expand Down
41 changes: 41 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,47 @@ function provider(input: {
}

describe("ThreadComposer provider authority", () => {
it("resolves Antigravity within its account and permits only bounded discovery for an empty catalog", () => {
const account = provider({ instanceId: "antigravity_work", driver: "antigravity" });
const modelSelection = { instanceId: account.instanceId, model: "antigravity-default" };
expect(
resolveThreadComposerAuthority({ serverConfig: { providers: [account] }, modelSelection }),
).toMatchObject({ modelSelection, providerAdmissionAvailable: true });
const discovered = {
...account,
models: [
{
slug: "work-model",
name: "Work Model",
isCustom: false,
capabilities: null,
isDefault: true,
},
],
};
expect(
resolveThreadComposerAuthority({ serverConfig: { providers: [discovered] }, modelSelection }),
).toMatchObject({
modelSelection: { ...modelSelection, model: "work-model" },
providerAdmissionAvailable: true,
});
expect(
resolveThreadComposerAuthority({
serverConfig: { providers: [discovered] },
modelSelection: { ...modelSelection, model: "removed" },
}),
).toMatchObject({
providerAdmissionAvailable: false,
providerAdmissionReason: expect.stringContaining("no longer available"),
});
expect(threadComposerShowsStopAction("running")).toBe(true);
expect(
resolveThreadComposerAuthority({
serverConfig: { providers: [{ ...account, auth: { status: "unauthenticated" } }] },
modelSelection,
}).providerAdmissionAvailable,
).toBe(false);
});
it("shows and blocks the unavailable Prime binding instead of a local Codex overlay", () => {
const prime = provider({
instanceId: "primeAgent",
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ import {
buildModelOptions,
type ModelOption,
groupByProvider,
modelSelectionDisplayName,
resolveModelSelectionRuntimeMode,
showModelSelectionInteractionModeToggle,
} from "../../lib/modelOptions";
Expand Down Expand Up @@ -1954,9 +1955,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
accessibilityLabel="Model and reasoning settings"
emphasized
iconNode={
<ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
<ProviderIcon
provider={
currentModelOption?.providerDriver ?? selectedProviderStatus?.driver
}
size={16}
/>
}
label={
currentModelOption?.label ??
modelSelectionDisplayName(currentModelSelection)
}
label={currentModelOption?.label ?? currentModelSelection.model}
maxWidth="100%"
disabled={props.sessionInputBlocked}
accessibilityHint={
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ function ThreadSettingsSessionProvider(

const pressModel = useCallback(
(option: ModelOption) => {
if (props.getModelDisabledReason?.(option)) return;
if (option.isUnavailable || props.getModelDisabledReason?.(option)) return;
void Haptics.selectionAsync();
setPendingModel((current) =>
pendingModelAfterPress({
Expand Down Expand Up @@ -615,7 +615,12 @@ function ThreadSettingsModelListRow(props: {
onPress={onPress}
option={props.option}
selected={session.isDisplayed(props.option)}
disabledReason={session.getModelDisabledReason?.(props.option)}
disabledReason={
session.getModelDisabledReason?.(props.option) ??
(props.option.isUnavailable
? (props.option.unavailableReason ?? "This model is unavailable.")
: undefined)
}
/>
);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
const draftModelSelection =
selectedProjectDraft.providerSelectionExplicit === true && storedDraftModelSelection !== null
? storedDraftModelSelection
? (selectableDraftModelSelection ?? storedDraftModelSelection)
: selectableDraftModelSelection;
const projectDefaultModelSelection = resolveDefaultableModelSelection(
selectedEnvironmentServerConfig,
Expand Down Expand Up @@ -532,6 +532,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
stickySelection: stickyModelSelection,
modelOptions,
unavailablePreferredProvider,
providers: selectedEnvironmentServerConfig?.providers,
});
const selectedModelKey = selectedModel
? `${selectedModel.instanceId}:${selectedModel.model}`
Expand Down
143 changes: 142 additions & 1 deletion apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts";
import {
ANTIGRAVITY_DEFAULT_MODEL,
ProviderInstanceId,
type ModelSelection,
type ServerConfig,
} from "@t3tools/contracts";

import {
buildModelOptions,
Expand All @@ -15,8 +20,144 @@ import {
resolveNewTaskUnavailableProvider,
resolveSelectableModelSelection,
type ModelOption,
modelSelectionDisplayName,
} from "./modelOptions";

describe("Antigravity account catalog selection", () => {
const config = {
providers: [
{
instanceId: "antigravity_work",
driver: "antigravity",
enabled: true,
installed: true,
auth: { status: "authenticated" },
status: "ready",
models: [
{
slug: "work-model",
name: "Work Model",
isCustom: false,
capabilities: null,
isDefault: true,
isLegacy: true,
},
],
},
{
instanceId: "antigravity_personal",
driver: "antigravity",
enabled: true,
installed: true,
auth: { status: "authenticated" },
status: "ready",
models: [
{
slug: "personal-model",
name: "Personal Model",
isCustom: false,
capabilities: null,
isDefault: true,
},
],
},
],
} as unknown as ServerConfig;
const marker = {
instanceId: ProviderInstanceId.make("antigravity_work"),
model: ANTIGRAVITY_DEFAULT_MODEL,
options: [{ id: "thinking", value: "high" }],
};
const blank = {
draftSelection: null,
projectDefaultSelection: null,
stickySelection: null,
modelOptions: [],
};

it("resolves the marker to its own account and preserves concrete legacy project choices", () => {
const resolved = { ...marker, model: "work-model" };
expect(resolveSelectableModelSelection(config, marker)).toEqual(resolved);
expect(resolveDefaultableModelSelection(config, resolved)).toEqual(resolved);
expect(
buildModelOptions(config, marker).find((option) => option.providerKey === marker.instanceId)
?.selection,
).toEqual(resolved);
expect(
buildModelOptions(config, marker).some(
(option) => option.selection.model === ANTIGRAVITY_DEFAULT_MODEL,
),
).toBe(false);
});

it("preserves a vanished selected model as unavailable instead of taking another account default", () => {
const vanished = { ...marker, model: "removed-model" };
const selected = resolveDefaultableModelSelection(config, vanished);
expect(selected).toEqual(vanished);
const options = buildModelOptions(config, selected);
expect(options.find((option) => option.selection.model === vanished.model)).toMatchObject({
isUnavailable: true,
unavailableReason: expect.stringContaining("no longer available"),
});
expect(canSendToModelSelection(config, selected)).toBe(false);
expect(
resolveNewTaskModelSelection({
...blank,
projectDefaultSelection: selected,
modelOptions: options,
}),
).toEqual(vanished);
});

it("can discover a first-run account on send without inventing a marker picker row", () => {
const emptyConfig = {
...config,
providers: config.providers.map((provider) => ({
...provider,
auth: { status: "unknown" as const },
models: [],
})),
};
const firstSelection = resolveNewTaskModelSelection({
...blank,
providers: emptyConfig.providers,
});
expect(firstSelection).toEqual({
instanceId: marker.instanceId,
model: ANTIGRAVITY_DEFAULT_MODEL,
});
expect(buildModelOptions(emptyConfig, firstSelection)).toEqual([]);
expect(canSendToModelSelection(emptyConfig, firstSelection)).toBe(true);
expect(modelSelectionDisplayName(marker)).toBe("Default model");
expect(resolveSelectableModelSelection(emptyConfig, marker)).toEqual(marker);
expect(
resolveNewTaskModelSelection({
...blank,
draftSelection: marker,
providers: config.providers,
}),
).toEqual(marker);
});

it("leaves concrete restart sends usable and prevents discovery through disabled accounts", () => {
const restarted = {
...config,
providers: config.providers.map((provider) => ({
...provider,
auth: { status: "unknown" as const },
models: [],
})),
};
expect(canSendToModelSelection(restarted, { ...marker, model: "saved-model" })).toBe(true);
const disabled = {
...restarted,
providers: restarted.providers.map((provider) => ({ ...provider, enabled: false })),
};
expect(resolveNewTaskModelSelection({ ...blank, providers: disabled.providers })).toBe(null);
expect(canSendToModelSelection(disabled, marker)).toBe(false);
});
});

describe("mobile model options", () => {
it("presents the default Prime Agent instance with its product name", () => {
const config = {
Expand Down
Loading
Loading