From 4af80571dc8840ac7031f2815512ef342cea4bba Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Sat, 26 Jul 2025 01:46:35 -0700 Subject: [PATCH 01/17] made stuff besides download progress --- .../welcome-modal/audio-permissions-view.tsx | 201 ++++++++++++++++++ .../calendar-permissions-view.tsx | 159 ++++++++++++++ .../src/components/welcome-modal/index.tsx | 75 +++++-- .../welcome-modal/language-selection-view.tsx | 146 +++++++++++++ 4 files changed, 566 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/components/welcome-modal/audio-permissions-view.tsx create mode 100644 apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx create mode 100644 apps/desktop/src/components/welcome-modal/language-selection-view.tsx diff --git a/apps/desktop/src/components/welcome-modal/audio-permissions-view.tsx b/apps/desktop/src/components/welcome-modal/audio-permissions-view.tsx new file mode 100644 index 0000000000..44b902e958 --- /dev/null +++ b/apps/desktop/src/components/welcome-modal/audio-permissions-view.tsx @@ -0,0 +1,201 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { CheckCircle2Icon, MicIcon, Volume2Icon } from "lucide-react"; +import { useState } from "react"; + +import { commands as listenerCommands } from "@hypr/plugin-listener"; +import { Button } from "@hypr/ui/components/ui/button"; +import PushableButton from "@hypr/ui/components/ui/pushable-button"; +import { Spinner } from "@hypr/ui/components/ui/spinner"; +import { cn } from "@hypr/ui/lib/utils"; + +interface PermissionItemProps { + icon: React.ReactNode; + title: string; + description: string; + done: boolean | undefined; + isPending: boolean; + onRequest: () => void; + showSystemSettings?: boolean; +} + +function PermissionItem({ + icon, + title, + description, + done, + isPending, + onRequest, + showSystemSettings = false, +}: PermissionItemProps) { + return ( +
+
+
+
{icon}
+
+
+
{title}
+
+ {done ? ( + + + Access Granted + + ) : ( + {description} + )} +
+
+
+
+ {!done && ( + <> + + + )} + {done && ( +
+ +
+ )} +
+
+ ); +} + +interface AudioPermissionsViewProps { + onContinue: () => void; +} + +export function AudioPermissionsView({ onContinue }: AudioPermissionsViewProps) { + const { t } = useLingui(); + const [showSystemSettingsHelp, setShowSystemSettingsHelp] = useState(false); + + const micPermissionStatus = useQuery({ + queryKey: ["micPermission"], + queryFn: () => listenerCommands.checkMicrophoneAccess(), + refetchInterval: 500, + }); + + const systemAudioPermissionStatus = useQuery({ + queryKey: ["systemAudioPermission"], + queryFn: () => listenerCommands.checkSystemAudioAccess(), + refetchInterval: 500, + }); + + const micPermission = useMutation({ + mutationFn: async () => { + try { + const result = await listenerCommands.requestMicrophoneAccess(); + return result; + } catch (error) { + console.error("Microphone permission request failed:", error); + // In development, the permission dialog might not appear + // Show system settings button immediately + setShowSystemSettingsHelp(true); + throw error; + } + }, + onSuccess: () => { + micPermissionStatus.refetch(); + setShowSystemSettingsHelp(false); + }, + onError: (error) => { + console.error("Microphone permission error:", error); + setShowSystemSettingsHelp(true); + }, + }); + + const capturePermission = useMutation({ + mutationFn: async () => { + try { + const result = await listenerCommands.requestSystemAudioAccess(); + return result; + } catch (error) { + console.error("System audio permission request failed:", error); + setShowSystemSettingsHelp(true); + throw error; + } + }, + onSuccess: () => { + systemAudioPermissionStatus.refetch(); + setShowSystemSettingsHelp(false); + }, + onError: (error) => { + console.error("System audio permission error:", error); + setShowSystemSettingsHelp(true); + }, + }); + + const allPermissionsGranted = micPermissionStatus.data && systemAudioPermissionStatus.data; + + return ( +
+

+ Audio Permissions +

+ +

+ Grant access to audio so Hyprnote can transcribe your meetings +

+ +
+ } + title={t`Microphone Access`} + description={t`Required to transcribe your voice during meetings`} + done={micPermissionStatus.data} + isPending={micPermission.isPending} + onRequest={() => micPermission.mutate({})} + /> + + } + title={t`System Audio Access`} + description={t`Required to transcribe other people's voice during meetings`} + done={systemAudioPermissionStatus.data} + isPending={capturePermission.isPending} + onRequest={() => capturePermission.mutate({})} + /> +
+ + + Continue + + + {!allPermissionsGranted && ( +

+ Grant both permissions to continue +

+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx b/apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx new file mode 100644 index 0000000000..4d8cab9b8a --- /dev/null +++ b/apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx @@ -0,0 +1,159 @@ +import { Trans } from "@lingui/react/macro"; +import { useQuery } from "@tanstack/react-query"; +import { type as getOsType } from "@tauri-apps/plugin-os"; +import { CalendarIcon, CheckCircle2Icon, UserIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { commands as appleCalendarCommands } from "@hypr/plugin-apple-calendar"; +import { Button } from "@hypr/ui/components/ui/button"; +import PushableButton from "@hypr/ui/components/ui/pushable-button"; +import { cn } from "@hypr/ui/lib/utils"; + +interface PermissionItemProps { + icon: React.ReactNode; + title: string; + description: string; + done: boolean | undefined; + onRequest: () => void; +} + +function PermissionItem({ + icon, + title, + description, + done, + onRequest, +}: PermissionItemProps) { + return ( +
+
+
+
{icon}
+
+
+
{title}
+
+ {done ? ( + + + Access Granted + + ) : ( + {description} + )} +
+
+
+
+ {!done && ( + + )} + {done && ( +
+ +
+ )} +
+
+ ); +} + +interface CalendarPermissionsViewProps { + onContinue: () => void; +} + +export function CalendarPermissionsView({ onContinue }: CalendarPermissionsViewProps) { + const calendarAccess = useQuery({ + queryKey: ["settings", "calendarAccess"], + queryFn: () => appleCalendarCommands.calendarAccessStatus(), + refetchInterval: 500, + }); + + const contactsAccess = useQuery({ + queryKey: ["settings", "contactsAccess"], + queryFn: () => appleCalendarCommands.contactsAccessStatus(), + refetchInterval: 500, + }); + + const handleRequestCalendarAccess = useCallback(() => { + if (getOsType() === "macos") { + appleCalendarCommands + .requestCalendarAccess() + .then(() => { + calendarAccess.refetch(); + }) + .catch((error) => { + console.error(error); + }); + } + }, [calendarAccess]); + + const handleRequestContactsAccess = useCallback(() => { + if (getOsType() === "macos") { + appleCalendarCommands + .requestContactsAccess() + .then(() => { + contactsAccess.refetch(); + }) + .catch((error) => { + console.error(error); + }); + } + }, [contactsAccess]); + + return ( +
+

+ Calendar & Contacts +

+ +

+ Connect your calendar and contacts for a better experience +

+ +
+ } + title="Calendar Access" + description="Track events & meetings" + done={calendarAccess.data} + onRequest={handleRequestCalendarAccess} + /> + + } + title="Contacts Access" + description="Import meeting participants" + done={contactsAccess.data} + onRequest={handleRequestContactsAccess} + /> +
+ + + Continue + + +

+ These permissions are optional but recommended +

+
+ ); +} \ No newline at end of file diff --git a/apps/desktop/src/components/welcome-modal/index.tsx b/apps/desktop/src/components/welcome-modal/index.tsx index 0388da8d75..18d9dcc696 100644 --- a/apps/desktop/src/components/welcome-modal/index.tsx +++ b/apps/desktop/src/components/welcome-modal/index.tsx @@ -10,6 +10,10 @@ import { commands as sfxCommands } from "@hypr/plugin-sfx"; import { Modal, ModalBody } from "@hypr/ui/components/ui/modal"; import { Particles } from "@hypr/ui/components/ui/particles"; +import { commands as dbCommands } from "@hypr/plugin-db"; +import { AudioPermissionsView } from "./audio-permissions-view"; +import { CalendarPermissionsView } from "./calendar-permissions-view"; +import { LanguageSelectionView } from "./language-selection-view"; import { ModelSelectionView } from "./model-selection-view"; import { WelcomeView } from "./welcome-view"; @@ -21,7 +25,8 @@ interface WelcomeModalProps { export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { const navigate = useNavigate(); const [port, setPort] = useState(null); - const [showModelSelection, setShowModelSelection] = useState(false); + const [currentStep, setCurrentStep] = useState<"welcome" | "model-selection" | "audio-permissions" | "language-selection" | "calendar-permissions">("welcome"); + const [selectedLanguages, setSelectedLanguages] = useState(["en"]); const selectSTTModel = useMutation({ mutationFn: (model: SupportedModel) => localSttCommands.setCurrentModel(model), @@ -73,14 +78,40 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { }, [isOpen]); const handleStartLocal = () => { - setShowModelSelection(true); + setCurrentStep("model-selection"); }; const handleModelSelected = (model: SupportedModel) => { selectSTTModel.mutate(model); - sessionStorage.setItem("model-download-toast-dismissed", "true"); + setCurrentStep("audio-permissions"); + }; + + const handleAudioPermissionsContinue = () => { + setCurrentStep("language-selection"); + }; + + const handleLanguageSelectionContinue = async (languages: string[]) => { + setSelectedLanguages(languages); + + // Save the selected languages to the database + try { + const config = await dbCommands.getConfig(); + await dbCommands.setConfig({ + ...config, + general: { + ...config.general, + spoken_languages: languages, + }, + }); + } catch (error) { + console.error("Failed to save language preferences:", error); + } + + setCurrentStep("calendar-permissions"); + }; + const handleCalendarPermissionsContinue = () => { onClose(); }; @@ -94,18 +125,32 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { >
- {!showModelSelection - ? ( - - ) - : ( - - )} + {currentStep === "welcome" && ( + + )} + {currentStep === "model-selection" && ( + + )} + {currentStep === "audio-permissions" && ( + + )} + {currentStep === "language-selection" && ( + + )} + {currentStep === "calendar-permissions" && ( + + )}
void; +} + +export function LanguageSelectionView({ onContinue }: LanguageSelectionViewProps) { + const [selectedLanguages, setSelectedLanguages] = useState(["en"]); + const [open, setOpen] = useState(false); + + const handleAddLanguage = (langCode: string) => { + if (!selectedLanguages.includes(langCode)) { + setSelectedLanguages([...selectedLanguages, langCode]); + } + setOpen(false); + }; + + const handleRemoveLanguage = (langCode: string) => { + if (selectedLanguages.length > 1) { + setSelectedLanguages(selectedLanguages.filter(l => l !== langCode)); + } + }; + + const handleContinue = () => { + onContinue(selectedLanguages); + }; + + return ( +
+

+ Select Your Languages +

+ +

+ Choose the languages you speak for better transcription accuracy +

+ +
+
+
+
+
+ {selectedLanguages.map((langCode) => ( + + {LANGUAGES_ISO_639_1[langCode as ISO_639_1_CODE]?.name || langCode} + {selectedLanguages.length > 1 && ( + + )} + + ))} +
+ {selectedLanguages.length === 0 && ( +

+ Select at least one language +

+ )} +
+ + + + + + + + + No language found. + + + {SUPPORTED_LANGUAGES.filter( + (lang) => !selectedLanguages.includes(lang) + ).map((lang) => { + const language = LANGUAGES_ISO_639_1[lang]; + return ( + handleAddLanguage(lang)} + className="flex items-center justify-between py-2" + > +
+
{language.name}
+
+ {language.nativeName} +
+
+
+ ); + })} +
+
+
+
+
+
+

+ Add languages you speak to improve transcription accuracy +

+
+ + + Continue + +
+ ); +} \ No newline at end of file From 4da2dce0c094468b4380084f08ed7c792c884d99 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Sat, 26 Jul 2025 02:19:03 -0700 Subject: [PATCH 02/17] before fixing design --- .../welcome-modal/download-progress-view.tsx | 230 ++++++++++++++++++ .../src/components/welcome-modal/index.tsx | 32 ++- .../welcome-modal/model-selection-view.tsx | 7 +- .../desktop/src/hooks/use-global-downloads.ts | 77 ++++++ apps/desktop/src/styles/globals.css | 20 ++ 5 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/components/welcome-modal/download-progress-view.tsx create mode 100644 apps/desktop/src/hooks/use-global-downloads.ts diff --git a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx new file mode 100644 index 0000000000..d114d0009a --- /dev/null +++ b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx @@ -0,0 +1,230 @@ +import { Trans } from "@lingui/react/macro"; +import { Channel } from "@tauri-apps/api/core"; +import { BrainIcon, CheckCircle2Icon, MicIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { commands as localLlmCommands, SupportedModel as SupportedModelLLM } from "@hypr/plugin-local-llm"; +import { commands as localSttCommands, SupportedModel } from "@hypr/plugin-local-stt"; +import { Progress } from "@hypr/ui/components/ui/progress"; +import PushableButton from "@hypr/ui/components/ui/pushable-button"; +import { cn } from "@hypr/ui/lib/utils"; + +interface ModelDownloadProgress { + channel: Channel; + progress: number; + error: boolean; + completed: boolean; +} + +interface DownloadProgressViewProps { + selectedSttModel: SupportedModel; + onContinue: () => void; +} + +const ModelProgressCard = ({ + title, + icon: Icon, + download, + size, +}: { + title: string; + icon: React.ElementType; + download: ModelDownloadProgress; + size: string; +}) => { + return ( +
+
+
+ +
+
+
+

{title}

+ ({size}) +
+
+ {download.error ? ( + Download failed + ) : download.completed ? ( + + + Ready + + ) : ( +
+ + + {Math.round(download.progress)}% + +
+ )} +
+
+
+ {download.completed && ( +
+ +
+ )} +
+ ); +}; + +const WAITING_MESSAGES = [ + "Downloading models may take a few minutes...", + "You are free to continue your setup...", + "Teaching your AI not to snitch...", + "Securing your data from prying eyes...", + "Preparing transcription capabilities...", + "Setting up local language models...", + "Building your AI fortress...", + "Hiding your AI from the NSA...", +]; + +export const DownloadProgressView = ({ + selectedSttModel, + onContinue, +}: DownloadProgressViewProps) => { + const [sttDownload, setSttDownload] = useState({ + channel: new Channel(), + progress: 0, + error: false, + completed: false, + }); + + const [llmDownload, setLlmDownload] = useState({ + channel: new Channel(), + progress: 0, + error: false, + completed: false, + }); + + const [currentMessageIndex, setCurrentMessageIndex] = useState(0); + + // Start downloads when component mounts + useEffect(() => { + // Start STT download + localSttCommands.downloadModel(selectedSttModel, sttDownload.channel); + + // Start LLM download + localLlmCommands.downloadModel("HyprLLM", llmDownload.channel); + + // Setup STT progress listener + sttDownload.channel.onmessage = (progress) => { + if (progress < 0) { + setSttDownload(prev => ({ ...prev, error: true })); + return; + } + + setSttDownload(prev => ({ + ...prev, + progress: Math.max(prev.progress, progress), + completed: progress >= 100, + })); + }; + + // Setup LLM progress listener + llmDownload.channel.onmessage = (progress) => { + if (progress < 0) { + setLlmDownload(prev => ({ ...prev, error: true })); + return; + } + + setLlmDownload(prev => ({ + ...prev, + progress: Math.max(prev.progress, progress), + completed: progress >= 100, + })); + }; + }, [selectedSttModel, sttDownload.channel, llmDownload.channel]); + + const bothCompleted = sttDownload.completed && llmDownload.completed; + const hasErrors = sttDownload.error || llmDownload.error; + + // Cycle through waiting messages + useEffect(() => { + if (!bothCompleted && !hasErrors) { + const interval = setInterval(() => { + setCurrentMessageIndex((prev) => (prev + 1) % WAITING_MESSAGES.length); + }, 3000); + return () => clearInterval(interval); + } + }, [bothCompleted, hasErrors]); + + return ( +
+

+ Downloading AI Models +

+ +

+ Setting up your private AI assistant +

+ +
+ + + +
+ + + Continue Setup + + + {/* Animated waiting messages */} +
+ {!bothCompleted && !hasErrors && ( +
+

+ {WAITING_MESSAGES[currentMessageIndex]} +

+
+ )} + + {bothCompleted && ( +

+ All models ready! +

+ )} + + {hasErrors && ( +

+ Some downloads failed, but you can continue +

+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/apps/desktop/src/components/welcome-modal/index.tsx b/apps/desktop/src/components/welcome-modal/index.tsx index 18d9dcc696..cf071fff10 100644 --- a/apps/desktop/src/components/welcome-modal/index.tsx +++ b/apps/desktop/src/components/welcome-modal/index.tsx @@ -1,9 +1,10 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { message } from "@tauri-apps/plugin-dialog"; import { useEffect, useState } from "react"; import { commands } from "@/types"; +import { showLlmModelDownloadToast, showSttModelDownloadToast } from "@/components/toast/shared"; import { commands as authCommands, events } from "@hypr/plugin-auth"; import { commands as localSttCommands, SupportedModel } from "@hypr/plugin-local-stt"; import { commands as sfxCommands } from "@hypr/plugin-sfx"; @@ -13,6 +14,7 @@ import { Particles } from "@hypr/ui/components/ui/particles"; import { commands as dbCommands } from "@hypr/plugin-db"; import { AudioPermissionsView } from "./audio-permissions-view"; import { CalendarPermissionsView } from "./calendar-permissions-view"; +import { DownloadProgressView } from "./download-progress-view"; import { LanguageSelectionView } from "./language-selection-view"; import { ModelSelectionView } from "./model-selection-view"; import { WelcomeView } from "./welcome-view"; @@ -24,9 +26,12 @@ interface WelcomeModalProps { export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); const [port, setPort] = useState(null); - const [currentStep, setCurrentStep] = useState<"welcome" | "model-selection" | "audio-permissions" | "language-selection" | "calendar-permissions">("welcome"); + const [currentStep, setCurrentStep] = useState<"welcome" | "model-selection" | "download-progress" | "audio-permissions" | "language-selection" | "calendar-permissions">("welcome"); const [selectedLanguages, setSelectedLanguages] = useState(["en"]); + const [selectedSttModel, setSelectedSttModel] = useState("QuantizedSmall"); + const [wentThroughDownloads, setWentThroughDownloads] = useState(false); const selectSTTModel = useMutation({ mutationFn: (model: SupportedModel) => localSttCommands.setCurrentModel(model), @@ -83,7 +88,13 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { const handleModelSelected = (model: SupportedModel) => { selectSTTModel.mutate(model); + setSelectedSttModel(model); sessionStorage.setItem("model-download-toast-dismissed", "true"); + setCurrentStep("download-progress"); + }; + + const handleDownloadProgressContinue = () => { + setWentThroughDownloads(true); setCurrentStep("audio-permissions"); }; @@ -115,6 +126,17 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { onClose(); }; + // When modal closes, show toasts if we went through downloads + useEffect(() => { + if (!isOpen && wentThroughDownloads) { + console.log("Welcome modal closed after downloads, showing toasts"); + + // Show both toasts since we started both downloads + showSttModelDownloadToast(selectedSttModel, undefined, queryClient); + showLlmModelDownloadToast("HyprLLM", undefined, queryClient); + } + }, [isOpen, wentThroughDownloads, selectedSttModel, queryClient]); + return ( )} + {currentStep === "download-progress" && ( + + )} {currentStep === "audio-permissions" && ( void; }) => { - const queryClient = useQueryClient(); const [selectedModel, setSelectedModel] = useState("QuantizedSmall"); const supportedSTTModels = useQuery({ @@ -71,8 +70,6 @@ export const ModelSelectionView = ({ }); const handleContinue = () => { - showSttModelDownloadToast(selectedModel, undefined, queryClient); - showLlmModelDownloadToast(undefined, undefined, queryClient); onContinue(selectedModel); }; diff --git a/apps/desktop/src/hooks/use-global-downloads.ts b/apps/desktop/src/hooks/use-global-downloads.ts new file mode 100644 index 0000000000..3788baffba --- /dev/null +++ b/apps/desktop/src/hooks/use-global-downloads.ts @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query"; + +import { commands as localLlmCommands, SupportedModel as SupportedModelLLM } from "@hypr/plugin-local-llm"; +import { commands as localSttCommands, SupportedModel } from "@hypr/plugin-local-stt"; + +interface GlobalDownloadState { + sttDownloading: boolean; + llmDownloading: boolean; + hasAnyDownloads: boolean; +} + +export const useGlobalDownloadState = (sttModel?: SupportedModel, llmModel?: SupportedModelLLM) => { + return useQuery({ + queryKey: ["global-downloads", sttModel, llmModel], + queryFn: async () => { + try { + const [sttDownloading, llmDownloading] = await Promise.all([ + sttModel ? localSttCommands.isModelDownloading(sttModel) : false, + llmModel ? localLlmCommands.isModelDownloading(llmModel) : false, + ]); + + return { + sttDownloading, + llmDownloading, + hasAnyDownloads: sttDownloading || llmDownloading, + }; + } catch (error) { + console.error("Error checking download state:", error); + return { + sttDownloading: false, + llmDownloading: false, + hasAnyDownloads: false, + }; + } + }, + refetchInterval: 2000, // Check every 2 seconds + enabled: !!(sttModel || llmModel), // Only run if we have models to check + }); +}; + +export const useCheckAnyDownloadsInProgress = () => { + return useQuery({ + queryKey: ["any-downloads-in-progress"], + queryFn: async () => { + try { + // Check all supported models for any ongoing downloads + const sttModels = await localSttCommands.listSupportedModels(); + const llmModels = await localLlmCommands.listSupportedModels(); + + const sttDownloadChecks = await Promise.all( + sttModels.map(model => localSttCommands.isModelDownloading(model)) + ); + + const llmDownloadChecks = await Promise.all( + llmModels.map(model => localLlmCommands.isModelDownloading(model)) + ); + + const sttDownloading = sttDownloadChecks.some(Boolean); + const llmDownloading = llmDownloadChecks.some(Boolean); + + return { + sttDownloading, + llmDownloading, + hasAnyDownloads: sttDownloading || llmDownloading, + }; + } catch (error) { + console.error("Error checking any downloads in progress:", error); + return { + sttDownloading: false, + llmDownloading: false, + hasAnyDownloads: false, + }; + } + }, + refetchInterval: 2000, + }); +}; \ No newline at end of file diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index f070d58c7b..d167cd26c9 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -113,3 +113,23 @@ body { @apply select-text; } } + +/* Animations */ +@keyframes fadeInOut { + 0% { + opacity: 0; + transform: translateY(10px); + } + 20% { + opacity: 1; + transform: translateY(0); + } + 80% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-10px); + } +} From c4251db8620da2fb5ef6046141c8f46f2d89da97 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Sat, 26 Jul 2025 10:11:05 -0700 Subject: [PATCH 03/17] design refining --- .../src/components/welcome-modal/download-progress-view.tsx | 2 +- .../src/components/welcome-modal/language-selection-view.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx index d114d0009a..a1da69dd5b 100644 --- a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx +++ b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx @@ -192,7 +192,7 @@ export const DownloadProgressView = ({ Continue Setup diff --git a/apps/desktop/src/components/welcome-modal/language-selection-view.tsx b/apps/desktop/src/components/welcome-modal/language-selection-view.tsx index 6248ca9b67..37ff28c570 100644 --- a/apps/desktop/src/components/welcome-modal/language-selection-view.tsx +++ b/apps/desktop/src/components/welcome-modal/language-selection-view.tsx @@ -103,7 +103,7 @@ export function LanguageSelectionView({ onContinue }: LanguageSelectionViewProps No language found. - + {SUPPORTED_LANGUAGES.filter( (lang) => !selectedLanguages.includes(lang) ).map((lang) => { From 0dbb04956a4bdf935825c64fad5e1448c2b3c710 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Sat, 26 Jul 2025 13:13:05 -0700 Subject: [PATCH 04/17] ui fixed --- .../settings/components/ai/stt-view.tsx | 2 +- .../welcome-modal/audio-permissions-view.tsx | 6 +- .../calendar-permissions-view.tsx | 4 +- .../welcome-modal/download-progress-view.tsx | 94 ++++++++----------- .../src/components/welcome-modal/index.tsx | 21 +++-- .../welcome-modal/language-selection-view.tsx | 4 +- .../welcome-modal/model-selection-view.tsx | 49 ++++------ .../welcome-modal/rating-display.tsx | 8 +- 8 files changed, 85 insertions(+), 103 deletions(-) diff --git a/apps/desktop/src/components/settings/components/ai/stt-view.tsx b/apps/desktop/src/components/settings/components/ai/stt-view.tsx index d165401a30..21480a46cf 100644 --- a/apps/desktop/src/components/settings/components/ai/stt-view.tsx +++ b/apps/desktop/src/components/settings/components/ai/stt-view.tsx @@ -91,7 +91,7 @@ export const sttModelMetadata: Record +

Audio Permissions

- Grant access to audio so Hyprnote can transcribe your meetings + Grant access to audio so Hyprnote can transcribe your meetings

-
+
} title={t`Microphone Access`} diff --git a/apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx b/apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx index 4d8cab9b8a..f46070b74e 100644 --- a/apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx +++ b/apps/desktop/src/components/welcome-modal/calendar-permissions-view.tsx @@ -117,7 +117,7 @@ export function CalendarPermissionsView({ onContinue }: CalendarPermissionsViewP }, [contactsAccess]); return ( -
+

Calendar & Contacts

@@ -126,7 +126,7 @@ export function CalendarPermissionsView({ onContinue }: CalendarPermissionsViewP Connect your calendar and contacts for a better experience

-
+
} title="Calendar Access" diff --git a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx index a1da69dd5b..60c90d1e92 100644 --- a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx +++ b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx @@ -5,7 +5,6 @@ import { useEffect, useState } from "react"; import { commands as localLlmCommands, SupportedModel as SupportedModelLLM } from "@hypr/plugin-local-llm"; import { commands as localSttCommands, SupportedModel } from "@hypr/plugin-local-stt"; -import { Progress } from "@hypr/ui/components/ui/progress"; import PushableButton from "@hypr/ui/components/ui/pushable-button"; import { cn } from "@hypr/ui/lib/utils"; @@ -48,28 +47,17 @@ const ModelProgressCard = ({ )} />
-
-

{title}

- ({size}) -
-
+
{title}
+
{download.error ? ( - Download failed + Download failed ) : download.completed ? ( - - + + Ready ) : ( -
- - - {Math.round(download.progress)}% - -
+ Size: {size} • {Math.round(download.progress)}% )}
@@ -83,17 +71,6 @@ const ModelProgressCard = ({ ); }; -const WAITING_MESSAGES = [ - "Downloading models may take a few minutes...", - "You are free to continue your setup...", - "Teaching your AI not to snitch...", - "Securing your data from prying eyes...", - "Preparing transcription capabilities...", - "Setting up local language models...", - "Building your AI fortress...", - "Hiding your AI from the NSA...", -]; - export const DownloadProgressView = ({ selectedSttModel, onContinue, @@ -164,8 +141,19 @@ export const DownloadProgressView = ({ } }, [bothCompleted, hasErrors]); + const WAITING_MESSAGES = [ + "Downloading models may take a few minutes...", + "You are free to continue your setup...", + "Teaching your AI not to snitch...", + "Securing your data from prying eyes...", + "Preparing transcription capabilities...", + "Setting up local language models...", + "Building your AI fortress...", + "Hiding your AI from the NSA...", + ]; + return ( -
+

Downloading AI Models

@@ -174,7 +162,7 @@ export const DownloadProgressView = ({ Setting up your private AI assistant

-
+
Continue Setup - {/* Animated waiting messages */} -
- {!bothCompleted && !hasErrors && ( -
-

+

+ {!bothCompleted && !hasErrors && ( + {WAITING_MESSAGES[currentMessageIndex]} -

-
- )} - - {bothCompleted && ( -

- All models ready! -

- )} - - {hasErrors && ( -

- Some downloads failed, but you can continue -

- )} + + )} + + {bothCompleted && ( + + All models ready! + + )} + + {hasErrors && ( + + Some downloads failed, but you can continue + + )} +

); -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/apps/desktop/src/components/welcome-modal/index.tsx b/apps/desktop/src/components/welcome-modal/index.tsx index cf071fff10..7f9eb5658a 100644 --- a/apps/desktop/src/components/welcome-modal/index.tsx +++ b/apps/desktop/src/components/welcome-modal/index.tsx @@ -18,6 +18,7 @@ import { DownloadProgressView } from "./download-progress-view"; import { LanguageSelectionView } from "./language-selection-view"; import { ModelSelectionView } from "./model-selection-view"; import { WelcomeView } from "./welcome-view"; +import { useGlobalDownloadState } from "@/hooks/use-global-downloads"; interface WelcomeModalProps { isOpen: boolean; @@ -126,16 +127,22 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { onClose(); }; - // When modal closes, show toasts if we went through downloads + const downloadState = useGlobalDownloadState(selectedSttModel, "HyprLLM"); + useEffect(() => { - if (!isOpen && wentThroughDownloads) { - console.log("Welcome modal closed after downloads, showing toasts"); + if (!isOpen && wentThroughDownloads && downloadState.data) { + console.log("Welcome modal closed, checking ongoing downloads"); + + // Only show toasts for downloads that are actually still running + if (downloadState.data.sttDownloading) { + showSttModelDownloadToast(selectedSttModel, undefined, queryClient); + } - // Show both toasts since we started both downloads - showSttModelDownloadToast(selectedSttModel, undefined, queryClient); - showLlmModelDownloadToast("HyprLLM", undefined, queryClient); + if (downloadState.data.llmDownloading) { + showLlmModelDownloadToast("HyprLLM", undefined, queryClient); + } } - }, [isOpen, wentThroughDownloads, selectedSttModel, queryClient]); + }, [isOpen, wentThroughDownloads, selectedSttModel, queryClient, downloadState.data]); return ( +

Select Your Languages

@@ -55,7 +55,7 @@ export function LanguageSelectionView({ onContinue }: LanguageSelectionViewProps Choose the languages you speak for better transcription accuracy

-
+
diff --git a/apps/desktop/src/components/welcome-modal/model-selection-view.tsx b/apps/desktop/src/components/welcome-modal/model-selection-view.tsx index b97415d72c..af6e8a6010 100644 --- a/apps/desktop/src/components/welcome-modal/model-selection-view.tsx +++ b/apps/desktop/src/components/welcome-modal/model-selection-view.tsx @@ -4,13 +4,6 @@ import { BrainIcon, Zap as SpeedIcon } from "lucide-react"; import React, { useState } from "react"; import { Card, CardContent } from "@hypr/ui/components/ui/card"; -import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, -} from "@hypr/ui/components/ui/carousel"; import { SupportedModel } from "@hypr/plugin-local-stt"; @@ -32,14 +25,14 @@ const RatingDisplay = ( icon: React.ElementType; }, ) => ( -
- {label} -
+
+ {label} +
{[...Array(maxRating)].map((_, i) => ( @@ -79,15 +72,14 @@ export const ModelSelectionView = ({ Select a transcribing model -
- - - {supportedSTTModels.data?.map(modelInfo => { +
+
+ {supportedSTTModels.data + ?.filter(modelInfo => { + const model = modelInfo.model; + return ['QuantizedTiny', 'QuantizedSmall', 'QuantizedLargeTurbo'].includes(model); + }) + ?.map(modelInfo => { const model = modelInfo.model; const metadata = sttModelMetadata[model as SupportedModel]; if (!metadata) { @@ -97,8 +89,8 @@ export const ModelSelectionView = ({ const isSelected = selectedModel === model; return ( - -
+
+
setSelectedModel(model as SupportedModel)} > - +
-
{metadata.name}
+
{metadata.name}
{metadata.description}
@@ -130,15 +122,10 @@ export const ModelSelectionView = ({
- +
); })} - -
- - -
- +
( -
- {label} -
+
+ {label} +
{[...Array(maxRating)].map((_, i) => ( From 04c40d2982cea0507bdc051875c9184982718fc7 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Sat, 26 Jul 2025 14:52:47 -0700 Subject: [PATCH 05/17] things are fine - pre experiment --- .../welcome-modal/download-progress-view.tsx | 95 +++++++++++++------ .../src/components/welcome-modal/index.tsx | 33 ++++--- .../components/welcome-modal/welcome-view.tsx | 2 +- .../desktop/src/hooks/use-global-downloads.ts | 77 --------------- apps/desktop/src/routes/app.tsx | 8 ++ crates/db-user/assets/thank-you.md | 7 +- 6 files changed, 100 insertions(+), 122 deletions(-) delete mode 100644 apps/desktop/src/hooks/use-global-downloads.ts diff --git a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx index 60c90d1e92..e6c747998e 100644 --- a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx +++ b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx @@ -152,42 +152,49 @@ export const DownloadProgressView = ({ "Hiding your AI from the NSA...", ]; + // Handle completion and server setup + useEffect(() => { + const handleSttCompletion = async () => { + if (sttDownload.completed) { + try { + // Set the selected model as current + await localSttCommands.setCurrentModel(selectedSttModel); + // Start the STT server + await localSttCommands.startServer(); + console.log("STT model set and server started"); + } catch (error) { + console.error("Error setting up STT:", error); + } + } + }; + + const handleLlmCompletion = async () => { + if (llmDownload.completed) { + try { + // Set HyprLLM as current model + await localLlmCommands.setCurrentModel("HyprLLM"); + // Start the LLM server + await localLlmCommands.startServer(); + console.log("LLM model set and server started"); + } catch (error) { + console.error("Error setting up LLM:", error); + } + } + }; + + handleSttCompletion(); + handleLlmCompletion(); + }, [sttDownload.completed, llmDownload.completed, selectedSttModel]); + return (

Downloading AI Models

-

- Setting up your private AI assistant -

- -
- - - -
- - - Continue Setup - - - {/* Bottom animated text - width constrained */} -
-

+ {/* Replace static text with animated messages */} +

+

{!bothCompleted && !hasErrors && (

+ +
+ + + +
+ + + Continue Setup + + + {/* Bottom text similar to calendar view */} +

+ Downloads will continue in the background +

); }; \ No newline at end of file diff --git a/apps/desktop/src/components/welcome-modal/index.tsx b/apps/desktop/src/components/welcome-modal/index.tsx index 7f9eb5658a..4e388ddf4b 100644 --- a/apps/desktop/src/components/welcome-modal/index.tsx +++ b/apps/desktop/src/components/welcome-modal/index.tsx @@ -18,7 +18,7 @@ import { DownloadProgressView } from "./download-progress-view"; import { LanguageSelectionView } from "./language-selection-view"; import { ModelSelectionView } from "./model-selection-view"; import { WelcomeView } from "./welcome-view"; -import { useGlobalDownloadState } from "@/hooks/use-global-downloads"; +import { commands as localLlmCommands } from "@hypr/plugin-local-llm"; interface WelcomeModalProps { isOpen: boolean; @@ -127,22 +127,31 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { onClose(); }; - const downloadState = useGlobalDownloadState(selectedSttModel, "HyprLLM"); useEffect(() => { - if (!isOpen && wentThroughDownloads && downloadState.data) { + if (!isOpen && wentThroughDownloads) { console.log("Welcome modal closed, checking ongoing downloads"); - // Only show toasts for downloads that are actually still running - if (downloadState.data.sttDownloading) { - showSttModelDownloadToast(selectedSttModel, undefined, queryClient); - } - - if (downloadState.data.llmDownloading) { - showLlmModelDownloadToast("HyprLLM", undefined, queryClient); - } + const checkAndShowToasts = async () => { + try { + const sttModelExists = await localSttCommands.isModelDownloaded(selectedSttModel as SupportedModel); + const llmModelExists = await localLlmCommands.isModelDownloaded("HyprLLM"); + + if (!sttModelExists) { + showSttModelDownloadToast(selectedSttModel, undefined, queryClient); + } + + if (!llmModelExists) { + showLlmModelDownloadToast("HyprLLM", undefined, queryClient); + } + } catch (error) { + console.error("Error checking model download status:", error); + } + }; + + checkAndShowToasts(); } - }, [isOpen, wentThroughDownloads, selectedSttModel, queryClient, downloadState.data]); + }, [isOpen, wentThroughDownloads, selectedSttModel, queryClient]); return ( = ({ portReady, onGetStarte once className="mb-20 text-center text-2xl font-medium text-neutral-600" > - {t`The AI Meeting Notepad`} + {t`Where Conversations Stay Yours`} { - return useQuery({ - queryKey: ["global-downloads", sttModel, llmModel], - queryFn: async () => { - try { - const [sttDownloading, llmDownloading] = await Promise.all([ - sttModel ? localSttCommands.isModelDownloading(sttModel) : false, - llmModel ? localLlmCommands.isModelDownloading(llmModel) : false, - ]); - - return { - sttDownloading, - llmDownloading, - hasAnyDownloads: sttDownloading || llmDownloading, - }; - } catch (error) { - console.error("Error checking download state:", error); - return { - sttDownloading: false, - llmDownloading: false, - hasAnyDownloads: false, - }; - } - }, - refetchInterval: 2000, // Check every 2 seconds - enabled: !!(sttModel || llmModel), // Only run if we have models to check - }); -}; - -export const useCheckAnyDownloadsInProgress = () => { - return useQuery({ - queryKey: ["any-downloads-in-progress"], - queryFn: async () => { - try { - // Check all supported models for any ongoing downloads - const sttModels = await localSttCommands.listSupportedModels(); - const llmModels = await localLlmCommands.listSupportedModels(); - - const sttDownloadChecks = await Promise.all( - sttModels.map(model => localSttCommands.isModelDownloading(model)) - ); - - const llmDownloadChecks = await Promise.all( - llmModels.map(model => localLlmCommands.isModelDownloading(model)) - ); - - const sttDownloading = sttDownloadChecks.some(Boolean); - const llmDownloading = llmDownloadChecks.some(Boolean); - - return { - sttDownloading, - llmDownloading, - hasAnyDownloads: sttDownloading || llmDownloading, - }; - } catch (error) { - console.error("Error checking any downloads in progress:", error); - return { - sttDownloading: false, - llmDownloading: false, - hasAnyDownloads: false, - }; - } - }, - refetchInterval: 2000, - }); -}; \ No newline at end of file diff --git a/apps/desktop/src/routes/app.tsx b/apps/desktop/src/routes/app.tsx index 4efad68f4e..dd802990e9 100644 --- a/apps/desktop/src/routes/app.tsx +++ b/apps/desktop/src/routes/app.tsx @@ -20,6 +20,7 @@ import { SettingsProvider, useLeftSidebar, useRightPanel, + useHypr, } from "@/contexts"; import { commands } from "@/types"; import { commands as listenerCommands } from "@hypr/plugin-listener"; @@ -38,6 +39,7 @@ export const Route = createFileRoute("/app")({ function Component() { const router = useRouter(); + const { thankYouSessionId } = useHypr(); const { sessionsStore, ongoingSessionStore, isOnboardingNeeded, isIndividualizationNeeded } = Route.useLoaderData(); const [onboardingCompletedThisSession, setOnboardingCompletedThisSession] = useState(false); @@ -87,6 +89,12 @@ function Component() { onClose={() => { commands.setOnboardingNeeded(false); setOnboardingCompletedThisSession(true); + + // Navigate to thank you session if it exists + if (thankYouSessionId) { + router.navigate({ to: `/app/note/${thankYouSessionId}` }); + } + router.invalidate(); }} /> diff --git a/crates/db-user/assets/thank-you.md b/crates/db-user/assets/thank-you.md index c82e3eef76..f8c364525e 100644 --- a/crates/db-user/assets/thank-you.md +++ b/crates/db-user/assets/thank-you.md @@ -1,7 +1,10 @@ -We appreciate your patience while you wait for your STT&LLM models to be downloaded. +We appreciate your patience while you wait for your **STT&LLM models** to be downloaded. +
-**In the meantime...** why don't you check out [our blog](https://hyprnote.com/blog) or [changelog](https://hyprnote.canny.io/changelog) for a better understanding of the service. We also have @[Onboarding Video](note:df1d8c52-6d9d-4471-aff1-5dbd35899cbe) for you to get started. +You will be able to try out Meeting Transcription after STT download is complete (the recording button will **turn RED** when it's ready!), and Meeting Summarization after LLM download is complete. +
+**In the meantime...** why don't you check out [docs](https://docs.hyprnote.com/using-hyprnote/getting-started) or [blog](https://hyprnote.com/blog) for a better understanding of the service. We also have @[Onboarding Video](note:df1d8c52-6d9d-4471-aff1-5dbd35899cbe) for you to get started.
Also, please [join our Discord](https://hyprnote.com/discord)! We really want to hear from you. From e4d4dfc8c2c7b7c3386f876c2642e2dfa1a17417 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Sat, 26 Jul 2025 15:00:21 -0700 Subject: [PATCH 06/17] added progress bars --- .../src/components/welcome-modal/download-progress-view.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx index e6c747998e..1c1911d178 100644 --- a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx +++ b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx @@ -7,6 +7,7 @@ import { commands as localLlmCommands, SupportedModel as SupportedModelLLM } fro import { commands as localSttCommands, SupportedModel } from "@hypr/plugin-local-stt"; import PushableButton from "@hypr/ui/components/ui/pushable-button"; import { cn } from "@hypr/ui/lib/utils"; +import { Progress } from "@hypr/ui/components/ui/progress"; interface ModelDownloadProgress { channel: Channel; @@ -57,7 +58,10 @@ const ModelProgressCard = ({ Ready ) : ( - Size: {size} • {Math.round(download.progress)}% +
+ Size: {size} • {Math.round(download.progress)}% + +
)}
From db65fd56e165436ff1cc56bbd6b591368b55e5d4 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Sat, 26 Jul 2025 17:34:14 -0700 Subject: [PATCH 07/17] ui all flushed out --- .../welcome-modal/download-progress-view.tsx | 20 +++++++++++-------- .../welcome-modal/language-selection-view.tsx | 8 +++++--- apps/desktop/src/routes/app.tsx | 6 +++--- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx index 1c1911d178..86387254b0 100644 --- a/apps/desktop/src/components/welcome-modal/download-progress-view.tsx +++ b/apps/desktop/src/components/welcome-modal/download-progress-view.tsx @@ -8,6 +8,7 @@ import { commands as localSttCommands, SupportedModel } from "@hypr/plugin-local import PushableButton from "@hypr/ui/components/ui/pushable-button"; import { cn } from "@hypr/ui/lib/utils"; import { Progress } from "@hypr/ui/components/ui/progress"; +import { sttModelMetadata } from "../settings/components/ai/stt-view"; interface ModelDownloadProgress { channel: Channel; @@ -140,7 +141,7 @@ export const DownloadProgressView = ({ if (!bothCompleted && !hasErrors) { const interval = setInterval(() => { setCurrentMessageIndex((prev) => (prev + 1) % WAITING_MESSAGES.length); - }, 3000); + }, 4000); return () => clearInterval(interval); } }, [bothCompleted, hasErrors]); @@ -149,11 +150,11 @@ export const DownloadProgressView = ({ "Downloading models may take a few minutes...", "You are free to continue your setup...", "Teaching your AI not to snitch...", - "Securing your data from prying eyes...", - "Preparing transcription capabilities...", - "Setting up local language models...", + "Running vibe_check.exe...", + "Securing your data from enemies...", "Building your AI fortress...", - "Hiding your AI from the NSA...", + "Wiping fingerprints off the algorithm...", + "Installing integrity.exe (beta)...", ]; // Handle completion and server setup @@ -190,6 +191,9 @@ export const DownloadProgressView = ({ handleLlmCompletion(); }, [sttDownload.completed, llmDownload.completed, selectedSttModel]); + // Get the actual metadata for the selected STT model + const sttMetadata = sttModelMetadata[selectedSttModel]; + return (

@@ -204,7 +208,7 @@ export const DownloadProgressView = ({ key={currentMessageIndex} className="transition-all duration-500 ease-in-out block" style={{ - animation: 'fadeInOut 3s ease-in-out', + animation: 'fadeInOut 4s ease-in-out', }} > {WAITING_MESSAGES[currentMessageIndex]} @@ -230,14 +234,14 @@ export const DownloadProgressView = ({ title="Speech Recognition" icon={MicIcon} download={sttDownload} - size="250MB" + size={sttMetadata?.size || "250MB"} />

diff --git a/apps/desktop/src/components/welcome-modal/language-selection-view.tsx b/apps/desktop/src/components/welcome-modal/language-selection-view.tsx index 5657cde493..be567b5e8b 100644 --- a/apps/desktop/src/components/welcome-modal/language-selection-view.tsx +++ b/apps/desktop/src/components/welcome-modal/language-selection-view.tsx @@ -36,9 +36,11 @@ export function LanguageSelectionView({ onContinue }: LanguageSelectionViewProps }; const handleRemoveLanguage = (langCode: string) => { - if (selectedLanguages.length > 1) { - setSelectedLanguages(selectedLanguages.filter(l => l !== langCode)); + // Don't allow removing English or if it would leave us with no languages + if (langCode === "en" || selectedLanguages.length <= 1) { + return; } + setSelectedLanguages(selectedLanguages.filter(l => l !== langCode)); }; const handleContinue = () => { @@ -67,7 +69,7 @@ export function LanguageSelectionView({ onContinue }: LanguageSelectionViewProps className="flex items-center gap-1 px-2.5 py-1 text-sm bg-neutral-100 text-neutral-700 border-neutral-300 hover:bg-neutral-200 transition-colors" > {LANGUAGES_ISO_639_1[langCode as ISO_639_1_CODE]?.name || langCode} - {selectedLanguages.length > 1 && ( + {selectedLanguages.length > 1 && langCode !== "en" && (