Skip to content
Merged
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
36 changes: 25 additions & 11 deletions apps/mobile/src/features/settings/CloudVoiceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,31 +40,45 @@ import {
cloudVoiceModelsAtom,
runSpeech,
} from "../voice-input/voiceDestination";
import { SettingsSection } from "./components/SettingsSection";

type CloudDestination = Extract<VoiceTranscriptionDestination, { kind: "cloud" }>;
type Props = { environment: EnvironmentPresentation; active: CloudDestination | null };
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 (
<SettingsSection title={`Cloud via ${environment.label}`}>
<ListActionRow
label="Cloud providers"
<>
<ListChoiceRow
label="Cloud provider"
description={
active
? `Using ${CLOUD_SPEECH_LABELS[active.provider]}`
: "Optional · use your own API key"
? `${CLOUD_SPEECH_LABELS[active.provider]}${active.provider === "openrouter" ? ` · ${active.model}` : ""}`
: usable
? "Use your own API key"
: "Not connected"
}
onPress={() => setOpen(true)}
selected={active !== null}
disabled={!usable}
separated
onPress={() => onOpenChange(true)}
/>
{open && (
<CloudChoices environment={environment} active={active} onClose={() => setOpen(false)} />
<CloudChoices
environment={environment}
active={active}
onClose={() => onOpenChange(false)}
/>
)}
</SettingsSection>
</>
);
}

Expand Down
145 changes: 99 additions & 46 deletions apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ 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 { 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";
Expand Down Expand Up @@ -127,45 +129,96 @@ 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 (
<ListChoiceRow
label={environment.label}
selected={selected}
disabled={!usable}
description={
!usable
? environment.connection.phase !== "connected"
? "Not connected"
: "Transcription unavailable"
: status?.supported === false
? status.reason
: status?.state === "missing-model"
? "No transcription model installed"
: (environment.displayUrl ?? "Send finished recordings to this environment.")
environment.connection.phase !== "connected"
? "Not connected"
: !usable
? "Transcription unavailable"
: (environment.displayUrl ?? "Send finished recordings to this environment.")
}
separated
onPress={() => {
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 local = useAtomValue(voiceConnectionAtom(id)) !== null;
const status = AsyncResult.getOrElse(useAtomValue(voiceSpeechStatusAtom(id)), () => null);
const destination = grant?.destination.environmentId === id ? grant.destination : null;
const cloudCapable =
environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true ||
destination?.kind === "cloud";
return (
<SettingsSection title={`Method on ${environment.label}`}>
<ListChoiceRow
label="Local model"
description={
!local
? "Not available on this environment"
: status?.supported === false
? status.reason
: status?.state === "missing-model"
? "No model installed"
: status
? status.model
: "Runs on the environment"
}
selected={destination?.kind === "environment"}
disabled={!local}
onPress={() => {
// The user already agreed to send audio to this environment.
if (destination?.kind !== "environment") saveDestination(id);
}}
/>
{cloudCapable ? (
<CloudVoiceChoice
environment={environment}
active={destination?.kind === "cloud" ? destination : null}
open={cloudOpen}
onOpenChange={onCloudOpenChange}
/>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</SettingsSection>
);
}

export function SettingsVoiceRouteScreen() {
const transcriber = useAtomValue(localVoiceTranscriberAtom);
const grant = useAtomValue(voiceGrantAtom);
Expand All @@ -175,6 +228,12 @@ 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<EnvironmentId | null>(null);
const sheetEnvironment = environments.find(
(environment) => environment.environmentId === cloudSheet,
);
const methodEnvironment = sheetEnvironment ?? grantEnvironment;
const insets = useSafeAreaInsets();
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
Expand Down Expand Up @@ -234,7 +293,7 @@ export function SettingsVoiceRouteScreen() {
<ListChoiceRow
label="This device"
description="Audio stays on this device and is transcribed here."
selected={!grant}
selected={!grant && !sheetEnvironment}
onPress={() => saveDestination(null)}
/>
{hasEnvironmentVoiceCapture &&
Expand All @@ -243,9 +302,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 ? (
Expand All @@ -259,27 +320,19 @@ export function SettingsVoiceRouteScreen() {
/>
) : null}
</SettingsSection>
{hasEnvironmentVoiceCapture &&
environments
.filter(
(env) =>
env.serverConfig?.environment.capabilities.cloudVoiceTranscription ||
(grant?.destination.kind === "cloud" &&
grant.destination.environmentId === env.environmentId),
)
.map((environment) => (
<CloudVoiceSection
key={environment.environmentId}
environment={environment}
active={
grant?.destination.kind === "cloud" &&
grant.destination.environmentId === environment.environmentId
? grant.destination
: null
}
/>
))}
{grantEnvironment && grant?.destination.kind === "environment" ? (
{hasEnvironmentVoiceCapture && methodEnvironment ? (
<EnvironmentMethodSection
environment={methodEnvironment}
grant={grant}
cloudOpen={cloudSheet === methodEnvironment.environmentId}
onCloudOpenChange={(open) =>
setCloudSheet(open ? methodEnvironment.environmentId : null)
}
/>
) : null}
{grantEnvironment &&
methodEnvironment === grantEnvironment &&
grant?.destination.kind === "environment" ? (
<VoiceModelSection
environmentId={grantEnvironment.environmentId}
label={grantEnvironment.label}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/settings/VoiceModelSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export function VoiceModelSection(props: {
const voice = useVoiceModels(props.environmentId, props.label);
const { status } = voice;
if (!voice.connection || !status) return null;
const title = `Transcription model on ${props.label}`;
const title = "Model";
if (!status.supported) {
return (
<SettingsSection title={title} footer={status.reason}>
Expand Down
Loading