diff --git a/apps/mobile/src/components/ui/ListRow.tsx b/apps/mobile/src/components/ui/ListRow.tsx
index de8bba51beb35..8f2ce11259bc9 100644
--- a/apps/mobile/src/components/ui/ListRow.tsx
+++ b/apps/mobile/src/components/ui/ListRow.tsx
@@ -1,17 +1,27 @@
-import { Platform } from "react-native";
+import { Platform, View } from "react-native";
import { useResolveClassNames } from "uniwind";
import { SymbolView } from "./AppSymbol";
import { ListRowLayout, type ListRowContentProps } from "./ListRowLayout";
+import { RadioIndicator } from "./RadioIndicator";
import { RowPressable } from "./RowPressable";
-export function ListRow(props: ListRowContentProps & { readonly onPress: () => void }) {
+export function ListRow(
+ props: ListRowContentProps & {
+ readonly onPress: () => void;
+ /** Marks the row as the chosen option while it still navigates. */
+ readonly selected?: boolean;
+ },
+) {
const rippleStyle = useResolveClassNames("bg-subtle-strong");
return (
v
+
+ {props.selected === undefined ? null : (
+
+ {Platform.OS === "android" ? (
+
+ ) : props.selected ? (
+
+ ) : null}
+
+ )}
+
+
}
/>
diff --git a/apps/mobile/src/features/settings/CloudVoiceSection.tsx b/apps/mobile/src/features/settings/CloudVoiceSection.tsx
index 684f8dbc83769..6ca61ece7b90a 100644
--- a/apps/mobile/src/features/settings/CloudVoiceSection.tsx
+++ b/apps/mobile/src/features/settings/CloudVoiceSection.tsx
@@ -21,7 +21,7 @@ import {
import * as Schema from "effect/Schema";
import Constants from "expo-constants";
import { AsyncResult } from "effect/reactivity";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
import { ScrollView, TextInput, View } from "react-native";
import { Sheet } from "../../components/ui/Sheet";
import * as Dialog from "../../components/ui/Dialog";
@@ -30,6 +30,7 @@ import { AppText as Text } from "../../components/ui/AppText";
import { ListActionRow } from "../../components/ui/ListActionRow";
import { ListChoiceRow } from "../../components/ui/ListChoiceRow";
import { Chip } from "../../components/ui/Chip";
+import { ListSection } from "../../components/ui/ListSection";
import type { EnvironmentPresentation } from "../../state/environments";
import { useEnvironmentScope } from "../../state/session";
import {
@@ -40,7 +41,6 @@ import {
cloudVoiceModelsAtom,
runSpeech,
} from "../voice-input/voiceDestination";
-import { SettingsSection } from "./components/SettingsSection";
type CloudDestination = Extract;
type Props = { environment: EnvironmentPresentation; active: CloudDestination | null };
@@ -48,23 +48,38 @@ const isApiKey = Schema.is(CloudSpeechApiKey);
const isModelId = Schema.is(CloudSpeechModelId);
const MODEL_ROWS = 6;
-export function CloudVoiceSection({ environment, active }: Props) {
- const [open, setOpen] = useState(false);
+/** The cloud method row for one environment; opens the provider sheet. */
+export function CloudVoiceChoice({
+ environment,
+ active,
+ open,
+ onOpenChange,
+}: Props & { open: boolean; onOpenChange: (open: boolean) => void }) {
+ const usable = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null;
return (
-
-
+ setOpen(true)}
+ selected={active !== null}
+ disabled={!usable}
+ separated
+ onPress={() => onOpenChange(true)}
/>
{open && (
- setOpen(false)} />
+ onOpenChange(false)}
+ />
)}
-
+ >
);
}
@@ -92,54 +107,70 @@ function ModelPicker({
const shown = showAll ? matches : matches.slice(0, MODEL_ROWS);
const delisted = current !== undefined && !models.some((model) => model.id === current);
return (
- <>
-
- {delisted ? (
-
- OpenRouter no longer lists {current}. Dictation stays off until you choose another model.
-
- ) : null}
+
+
+
+
{selected && !shown.some((model) => model.id === selected.id) ? (
{}}
/>
) : null}
- {shown.map((model, index) => (
+ {shown.map((model) => (
0}
+ separated
disabled={disabled}
onPress={() => onChange(model.id)}
/>
))}
{matches.length === 0 ? (
- No transcription model matches.
+
+
+ No transcription model matches.
+
+
) : matches.length > shown.length ? (
- setShowAll(true)}
- />
+
+ setShowAll(true)}
+ />
+
) : null}
- >
+
);
}
+/** A hairline above a row that has no `separated` prop of its own. */
+function Separated({ children }: { children: ReactNode }) {
+ return {children};
+}
+
function CloudChoices({ environment, active, onClose }: Props & { onClose: () => void }) {
const id = environment.environmentId;
const connection = useAtomValue(cloudVoiceConnectionAtom(id));
@@ -223,58 +254,35 @@ function CloudChoices({ environment, active, onClose }: Props & { onClose: () =>
onClose();
};
const catalog = AsyncResult.isSuccess(models) ? (models.value?.models ?? null) : null;
- const content =
- !AsyncResult.isSuccess(keys) || !keys.value || !connection ? (
-
- ) : (
- CloudSpeechProvider.literals.map((provider) => {
- if (editing && provider !== editing.provider) return null;
- const label = CLOUD_SPEECH_LABELS[provider];
- const company = CLOUD_SPEECH_PROVIDER_NAMES[provider];
- const routed = provider === "openrouter";
- const disclosure = cloudSpeechDisclosure(provider, environment.label);
- const saved = keys.value![provider];
- const isActive = active?.provider === provider;
- const isEditing = editing?.provider === provider;
- const modelChosen =
- isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false);
- const modelChanged = model !== (activeModel ?? "");
- const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider];
- const keyChanged =
- credential !== undefined &&
- !(
- isSecretReference(credential) &&
- isSecretReference(original) &&
- original.provider === credential.provider &&
- original.reference === credential.reference
- );
- const routedReady =
- modelChosen &&
- (draft.trim() ? credential !== undefined : Boolean(saved)) &&
- (!isActive || modelChanged || keyChanged);
- const saveRouted = () => {
- if (!routedReady || !isModelId(model)) return;
- const grantChanges = !isActive || modelChanged;
- if (credential !== undefined && keyChanged)
- return void changeKey(provider, credential, model);
- // Model-only changes never write to the environment.
- if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model));
- onClose();
- };
- return (
-
- {!isEditing && (
+ const content = (() => {
+ if (!AsyncResult.isSuccess(keys) || !keys.value || !connection)
+ return (
+
+
+
+ );
+ const saved = keys.value;
+ if (!editing)
+ return (
+
+ {CloudSpeechProvider.literals.map((provider, index) => {
+ const label = CLOUD_SPEECH_LABELS[provider];
+ const routed = provider === "openrouter";
+ const key = saved[provider];
+ const isActive = active?.provider === provider;
+ return (
/>
}
selected={isActive}
- disabled={saved === undefined || (!saved && !canManage) || saving}
+ separated={index > 0}
+ disabled={key === undefined || (!key && !canManage) || saving}
description={
- saved === undefined
+ key === undefined
? "Update the environment to use this provider"
: isActive && routed
? `${activeModel} · Tap to change model${canManage ? " or key" : ""}`
- : isActive && saved && canManage
+ : isActive && key && canManage
? "Tap to manage key"
- : saved
+ : key
? routed
? "API key saved · choose a model to use it"
- : "API key saved on this environment"
+ : "API key saved"
: canManage
? "Add an API key to use this provider"
: "Ask an environment administrator to add an API key"
}
onPress={() => {
if (routed) return edit(provider, !isActive);
- if (!saved) return edit(provider, true);
+ if (!key) return edit(provider, true);
if (isActive) {
if (canManage) edit(provider, false);
return;
}
Dialog.confirm({
title: `Use ${label} for dictation?`,
- message: `${disclosure} Select This device to stop sharing.`,
+ message: `${cloudSpeechDisclosure(provider, environment.label)} Select This device to stop sharing.`,
confirmText: `Use ${label}`,
onConfirm: () => enable(pendingCloudVoiceChoice(id, provider)),
});
}}
/>
- )}
- {isEditing && routed && (
- <>
- {disclosure}
- {catalog ? (
-
+ );
+
+ const provider = editing.provider;
+ const label = CLOUD_SPEECH_LABELS[provider];
+ const company = CLOUD_SPEECH_PROVIDER_NAMES[provider];
+ const routed = provider === "openrouter";
+ const disclosure = cloudSpeechDisclosure(provider, environment.label);
+ const key = saved[provider];
+ const isActive = active?.provider === provider;
+ const modelChosen = isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false);
+ const modelChanged = model !== (activeModel ?? "");
+ const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider];
+ const keyChanged =
+ credential !== undefined &&
+ !(
+ isSecretReference(credential) &&
+ isSecretReference(original) &&
+ original.provider === credential.provider &&
+ original.reference === credential.reference
+ );
+ const routedReady =
+ modelChosen &&
+ (draft.trim() ? credential !== undefined : Boolean(key)) &&
+ (!isActive || modelChanged || keyChanged);
+ const saveRouted = () => {
+ if (!routedReady || !isModelId(model)) return;
+ const grantChanges = !isActive || modelChanged;
+ if (credential !== undefined && keyChanged)
+ return void changeKey(provider, credential, model);
+ // Model-only changes never write to the environment.
+ if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model));
+ onClose();
+ };
+ const submit = () => {
+ if (routed) saveRouted();
+ else if (credential) void changeKey(provider, credential);
+ };
+ const keyFooter =
+ draft && !credential
+ ? (source?.invalidReferenceMessage ?? "Enter a valid API key.")
+ : routed
+ ? key && !credential
+ ? "Uses the API key saved on this environment. The model choice applies only to this device."
+ : `Anyone with dictation access to ${environment.label} can bill this key.`
+ : editing.enable
+ ? `Anyone with dictation access to ${environment.label} can bill this key.`
+ : `Updates the ${company} key on ${environment.label}. Your dictation destination stays the same.`;
+ return (
+ <>
+ {disclosure}
+ {routed ? (
+ catalog ? (
+
+ ) : (
+
+
+
+ )
+ ) : null}
+ {canManage ? (
+
+
+ {sources.length > 0 && (
+
+
- ) : (
- {
+ setDraft("");
+ setSourceId(null);
+ }}
/>
- )}
- >
- )}
- {isEditing && (canManage || routed) && (
- <>
- {canManage && sources.length > 0 && (
-
+ {sources.map((candidate) => (
{
setDraft("");
- setSourceId(null);
+ setSourceId(candidate.id);
}}
/>
- {sources.map((candidate) => (
- {
- setDraft("");
- setSourceId(candidate.id);
- }}
- />
- ))}
-
- )}
- {canManage && (
- {
- if (routed) saveRouted();
- else if (credential) void changeKey(provider, credential);
- }}
- value={draft}
- onChangeText={setDraft}
- className="mx-4 rounded-lg bg-background px-3 py-2 text-foreground"
- />
- )}
- {routed && !saved && !canManage ? (
- {}}
- />
- ) : (
- {
- if (routed) saveRouted();
- else if (credential) void changeKey(provider, credential);
- }}
- />
- )}
- {saved && canManage && (
-
- Dialog.confirm({
- title: `Remove the ${label} key from ${environment.label}?`,
- message: "Everyone using it for dictation will lose access.",
- confirmText: "Remove key",
- destructive: true,
- onConfirm: () => void changeKey(provider, null),
- })
- }
- />
- )}
-
- >
+ ))}
+
+ )}
+
+
+
+ ) : null}
+ {canManage || routed ? (
+
+ {routed && !key && !canManage ? (
+ {}}
+ />
+ ) : (
+
)}
-
- );
- })
+ {key && canManage ? (
+
+
+ Dialog.confirm({
+ title: `Remove the ${label} key from ${environment.label}?`,
+ message: "Everyone using it for dictation will lose access.",
+ confirmText: "Remove key",
+ destructive: true,
+ onConfirm: () => void changeKey(provider, null),
+ })
+ }
+ />
+
+ ) : null}
+
+ ) : null}
+
+
+
+ >
);
+ })();
return (
{content}
diff --git a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx
index 5f7328214cb16..4b0ca00e88666 100644
--- a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx
@@ -24,13 +24,14 @@ import * as Dialog from "../../components/ui/Dialog";
import { useEnvironments, type EnvironmentPresentation } from "../../state/environments";
import {
chooseVoiceDestination,
+ cloudVoiceConnectionAtom,
voiceConnectionAtom,
voiceGrantAtom,
- voiceSpeechStatusAtom,
hasEnvironmentVoiceCapture,
} from "../voice-input/voiceDestination";
-import { VoiceModelSection } from "./VoiceModelSection";
-import { CloudVoiceSection } from "./CloudVoiceSection";
+import { LocalModelChoice } from "./VoiceModelSection";
+import { CloudVoiceChoice } from "./CloudVoiceSection";
+import type { VoiceGrant } from "../voice-input/voiceGrantStorage";
import type { EnvironmentId } from "@t3tools/contracts";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow";
@@ -127,45 +128,81 @@ function saveDestination(id: EnvironmentId | null) {
function EnvironmentChoice({
environment,
selected,
+ onCloudOnly,
}: {
environment: EnvironmentPresentation;
selected: boolean;
+ /** Called instead of a local grant when the environment only offers cloud transcription. */
+ onCloudOnly: () => void;
}) {
- const usable = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null;
- const status = AsyncResult.getOrElse(
- useAtomValue(voiceSpeechStatusAtom(environment.environmentId)),
- () => null,
- );
+ const local = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null;
+ const cloud = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null;
+ const usable = local || cloud;
return (
{
- if (!selected)
- Dialog.confirm({
- title: `Send microphone audio to ${environment.label}?`,
- message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`,
- confirmText: "Use environment",
- onConfirm: () => saveDestination(environment.environmentId),
- });
+ if (selected) return;
+ if (!local) return onCloudOnly();
+ Dialog.confirm({
+ title: `Send microphone audio to ${environment.label}?`,
+ message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`,
+ confirmText: "Use environment",
+ onConfirm: () => saveDestination(environment.environmentId),
+ });
}}
/>
);
}
+/** How the selected environment transcribes: its own model or a cloud provider. */
+function EnvironmentMethodSection({
+ environment,
+ grant,
+ cloudOpen,
+ onCloudOpenChange,
+}: {
+ environment: EnvironmentPresentation;
+ grant: VoiceGrant | null;
+ cloudOpen: boolean;
+ onCloudOpenChange: (open: boolean) => void;
+}) {
+ const id = environment.environmentId;
+ const destination = grant?.destination.environmentId === id ? grant.destination : null;
+ const cloudCapable =
+ environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true ||
+ destination?.kind === "cloud";
+ return (
+
+ saveDestination(id)}
+ />
+ {cloudCapable ? (
+
+ ) : null}
+
+ );
+}
+
export function SettingsVoiceRouteScreen() {
const transcriber = useAtomValue(localVoiceTranscriberAtom);
const grant = useAtomValue(voiceGrantAtom);
@@ -175,6 +212,15 @@ export function SettingsVoiceRouteScreen() {
(environment) => environment.environmentId === grant.destination.environmentId,
)
: undefined;
+ // An environment whose cloud sheet is open; it may not hold the grant yet.
+ const [cloudSheet, setCloudSheet] = useState(null);
+ // A sheet whose environment lost cloud transcription is gone, so its pending choice is too.
+ const sheetEnvironment = environments.find(
+ (environment) =>
+ environment.environmentId === cloudSheet &&
+ environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true,
+ );
+ const methodEnvironment = sheetEnvironment ?? grantEnvironment;
const insets = useSafeAreaInsets();
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
@@ -234,8 +280,11 @@ export function SettingsVoiceRouteScreen() {
saveDestination(null)}
+ selected={!grant && !sheetEnvironment}
+ onPress={() => {
+ setCloudSheet(null);
+ saveDestination(null);
+ }}
/>
{hasEnvironmentVoiceCapture &&
environments.map((environment) => (
@@ -243,9 +292,11 @@ export function SettingsVoiceRouteScreen() {
key={environment.environmentId}
environment={environment}
selected={
- grant?.destination.kind === "environment" &&
- grant.destination.environmentId === environment.environmentId
+ sheetEnvironment
+ ? sheetEnvironment.environmentId === environment.environmentId
+ : grant?.destination.environmentId === environment.environmentId
}
+ onCloudOnly={() => setCloudSheet(environment.environmentId)}
/>
))}
{isReady && grant && !grantEnvironment ? (
@@ -259,30 +310,14 @@ export function SettingsVoiceRouteScreen() {
/>
) : null}
- {hasEnvironmentVoiceCapture &&
- environments
- .filter(
- (env) =>
- env.serverConfig?.environment.capabilities.cloudVoiceTranscription ||
- (grant?.destination.kind === "cloud" &&
- grant.destination.environmentId === env.environmentId),
- )
- .map((environment) => (
-
- ))}
- {grantEnvironment && grant?.destination.kind === "environment" ? (
-
+ setCloudSheet(open ? methodEnvironment.environmentId : null)
+ }
/>
) : null}
{!grant && transcriber === null ? (
diff --git a/apps/mobile/src/features/settings/VoiceModelSection.tsx b/apps/mobile/src/features/settings/VoiceModelSection.tsx
index 2403f113b2af8..0bee9748b19cb 100644
--- a/apps/mobile/src/features/settings/VoiceModelSection.tsx
+++ b/apps/mobile/src/features/settings/VoiceModelSection.tsx
@@ -29,7 +29,6 @@ import {
voiceSpeechModelsAtom,
voiceSpeechStatusAtom,
} from "../voice-input/voiceDestination";
-import { SettingsSection } from "./components/SettingsSection";
const isDownloading = (model: EnvironmentSpeechModel) =>
model.state === "downloading" || model.state === "verifying";
@@ -272,51 +271,59 @@ export function VoiceModelRow(props: {
);
}
-export function VoiceModelSection(props: {
+/** The environment's local-model method: chosen by its mark, opens the model list. */
+export function LocalModelChoice(props: {
readonly environmentId: EnvironmentId;
readonly label: string;
+ readonly selected: boolean;
+ readonly onSelect: () => void;
}) {
const navigation = useNavigation();
const voice = useVoiceModels(props.environmentId, props.label);
const { status } = voice;
- if (!voice.connection || !status) return null;
- const title = `Transcription model on ${props.label}`;
- if (!status.supported) {
+ const open = () =>
+ navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId });
+ if (!voice.connection || !status || !status.supported)
return (
-
- {null}
-
+ {}}
+ />
);
- }
const active = status.state === "missing-model" ? null : status;
const pending = voice.models.find(isDownloading);
const progress =
pending?.downloaded === undefined ? undefined : pending.downloaded / pending.size;
return (
-
-
- navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId })
- }
- />
-
+ selected={props.selected}
+ onPress={() => {
+ if (!props.selected) props.onSelect();
+ // Switching to an installed model needs no further step.
+ if (props.selected || !active) open();
+ }}
+ />
);
}