Skip to content

Conversation

@ComputelessComputer
Copy link
Collaborator

@ComputelessComputer ComputelessComputer commented Dec 22, 2025

Summary

  • Added local Speech-to-Text (STT) model selection feature for Hyprnote during onboarding
  • Enables users to choose local STT models (Parakeet v2/v3) directly in the onboarding flow
  • Refactored shared code to eliminate duplication between onboarding and settings pages

Changes

  • Implemented local STT model selection UI in onboarding (configure-notice.tsx)
  • Created shared useLocalSttModel.ts hook with:
    • Query key factory (localSttKeys) following React Query best practices
    • Shared query options (localSttQueries) for isDownloaded and isDownloading
    • Reusable useLocalModelDownload hook for download state management
  • Updated settings page (configure.tsx) to use the shared hook
  • Re-exported sttModelQueries from shared location for backward compatibility

Review & Testing Checklist for Human

  • Test onboarding flow: navigate to local STT model selection, select a model, verify download starts
  • Test settings page: verify STT model download/cancel still works correctly with progress indicator
  • Verify "Already downloaded" indicator appears for previously downloaded models in both onboarding and settings
  • Check that query cache invalidation works correctly (download state updates in real-time)

Recommended test plan:

  1. Fresh install flow: Go through onboarding with ?local=true param, select Parakeet v2, verify download initiates
  2. Settings flow: Open Settings > AI > STT, expand Hyprnote provider, test download/cancel for a local model
  3. Cross-check: After downloading in settings, verify onboarding shows "Already downloaded"

Notes

  • Query keys changed from ["stt", "model", ...] to ["local-stt", "model", ...] - this is a minor breaking change for any existing cached queries but should be fine since these are polling queries with 1s refetch interval
  • The sttModelQueries export in shared.tsx is preserved as an alias for backward compatibility

Link to Devin run: https://app.devin.ai/sessions/988b07e83be847daa828d3bf1880c2b3
Requested by: yujonglee (@yujonglee)

@netlify
Copy link

netlify bot commented Dec 22, 2025

Deploy Preview for hyprnote-storybook ready!

Name Link
🔨 Latest commit 82d3ed7
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/694942c4b70972000844b09b
😎 Deploy Preview https://deploy-preview-2468--hyprnote-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Dec 22, 2025

Deploy Preview for hyprnote ready!

Name Link
🔨 Latest commit 82d3ed7
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/694942c42c404100084bef81
😎 Deploy Preview https://deploy-preview-2468--hyprnote.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 22, 2025

📝 Walkthrough

Walkthrough

This PR refactors local STT model download logic from an embedded component implementation into a reusable hook, and adds support for local STT configuration in the onboarding flow. A new useLocalModelDownload hook centralizes download lifecycle management, while conditional rendering enables local STT setup during onboarding when a URL parameter is present.

Changes

Cohort / File(s) Summary
Onboarding local STT flow
apps/desktop/src/components/onboarding/configure-notice.tsx
Conditionally renders LocalConfigureNotice component when search parameter includes local. Introduces LocalModelRow for rendering model options with download status. Exposes STEP_ID_CONFIGURE_NOTICE constant. Persists model selection via settings callbacks and triggers download commands.
Settings STT configure refactoring
apps/desktop/src/components/settings/ai/stt/configure.tsx
Removes local state management (useState, useEffect) and inline useLocalModelDownload implementation. Now imports external useLocalModelDownload hook from shared hooks module. Updates imports to remove sttModelQueries dependency.
Shared STT exports
apps/desktop/src/components/settings/ai/stt/shared.tsx
Replaces in-file sttModelQueries object with export alias to localSttQueries. Removes previous query definitions and adjusts imports accordingly.
Local STT model hook
apps/desktop/src/hooks/useLocalSttModel.ts
New file introducing useLocalModelDownload hook for managing download lifecycle, state tracking, and event subscriptions. Exports localSttKeys (query key definitions), localSttQueries (isDownloaded/isDownloading queries), and hook with progress, error state, and download/cancel controls.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • New hook implementation requires careful review of event subscription logic, state management patterns, and query configuration
  • Import/export changes across shared modules must be verified for consistency
  • Integration points between onboarding component and centralized hook logic should be validated
  • File: apps/desktop/src/hooks/useLocalSttModel.ts may warrant extra attention due to event handling and progress tracking logic

Possibly related PRs

Suggested reviewers

  • yujonglee

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding local STT model selection for Hyprnote, which is clearly supported by the detailed changes across multiple files.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, covering the feature implementation, refactoring work, and providing testing guidance for the PR.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/local-stt-model-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- Create useLocalSttModel.ts with shared query key factory (localSttKeys) and query options (localSttQueries)
- Move useLocalModelDownload hook to shared location for reuse
- Update configure.tsx (settings) to use shared hook
- Update configure-notice.tsx (onboarding) to use shared queries
- Remove duplicated sttModelQueries from both files

This follows React Query key factory patterns for better cache management and code reuse.

Co-Authored-By: yujonglee <[email protected]>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/desktop/src/components/onboarding/configure-notice.tsx (1)

181-181: Redundant query per row when parent already fetches download status.

LocalModelRow calls useQuery(localSttQueries.isDownloaded(model)) for each model, but the parent LocalConfigureNotice already queries the same data (p2Downloaded, p3Downloaded). Consider passing the downloaded status as a prop to avoid duplicate queries.

🔎 Proposed refactor to pass downloaded status as prop
 function LocalModelRow({
   model,
   displayName,
   description,
   isSelected,
   onSelect,
+  isDownloaded,
 }: {
   model: SupportedSttModel;
   displayName: string;
   description: string;
   isSelected: boolean;
   onSelect: () => void;
+  isDownloaded: boolean;
 }) {
-  const isDownloaded = useQuery(localSttQueries.isDownloaded(model));
 
   return (
     // ... use isDownloaded directly instead of isDownloaded.data
apps/desktop/src/hooks/useLocalSttModel.ts (2)

1-2: Consolidate duplicate imports.

useQuery and queryOptions can be imported from @tanstack/react-query in a single import statement.

🔎 Proposed fix
-import { queryOptions } from "@tanstack/react-query";
-import { useQuery } from "@tanstack/react-query";
+import { queryOptions, useQuery } from "@tanstack/react-query";

22-47: Consider the polling frequency impact.

Both isDownloaded and isDownloading queries use refetchInterval: 1000 (1 second). When multiple models are displayed (e.g., in the settings page with 5+ model rows), this creates frequent backend calls. Consider whether a longer interval (e.g., 2-3 seconds) would suffice, or use event-driven updates instead of polling for download status.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6cf957d and 82d3ed7.

📒 Files selected for processing (4)
  • apps/desktop/src/components/onboarding/configure-notice.tsx
  • apps/desktop/src/components/settings/ai/stt/configure.tsx
  • apps/desktop/src/components/settings/ai/stt/shared.tsx
  • apps/desktop/src/hooks/useLocalSttModel.ts
🧰 Additional context used
📓 Path-based instructions (5)
**/*

📄 CodeRabbit inference engine (AGENTS.md)

Format using dprint fmt from the root. Do not use cargo fmt.

Files:

  • apps/desktop/src/components/settings/ai/stt/shared.tsx
  • apps/desktop/src/components/onboarding/configure-notice.tsx
  • apps/desktop/src/hooks/useLocalSttModel.ts
  • apps/desktop/src/components/settings/ai/stt/configure.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Avoid creating a bunch of types/interfaces if they are not shared. Especially for function props. Just inline them.
Never do manual state management for form/mutation. Use useForm from tanstack-form and useQuery/useMutation from tanstack-query for 99% cases.

Files:

  • apps/desktop/src/components/settings/ai/stt/shared.tsx
  • apps/desktop/src/components/onboarding/configure-notice.tsx
  • apps/desktop/src/hooks/useLocalSttModel.ts
  • apps/desktop/src/components/settings/ai/stt/configure.tsx
**/*.{ts,tsx,rs,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

By default, avoid writing comments at all. If you write one, it should be about 'Why', not 'What'.

Files:

  • apps/desktop/src/components/settings/ai/stt/shared.tsx
  • apps/desktop/src/components/onboarding/configure-notice.tsx
  • apps/desktop/src/hooks/useLocalSttModel.ts
  • apps/desktop/src/components/settings/ai/stt/configure.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: If there are many classNames with conditional logic, use cn (import from @hypr/utils). Always pass an array and split by logical grouping.
Use motion/react instead of framer-motion.

Files:

  • apps/desktop/src/components/settings/ai/stt/shared.tsx
  • apps/desktop/src/components/onboarding/configure-notice.tsx
  • apps/desktop/src/hooks/useLocalSttModel.ts
  • apps/desktop/src/components/settings/ai/stt/configure.tsx
**/*.ts

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.ts: Agent implementations should use TypeScript and follow the established architectural patterns defined in the agent framework
Agent communication should use defined message protocols and interfaces

Files:

  • apps/desktop/src/hooks/useLocalSttModel.ts
🧠 Learnings (1)
📚 Learning: 2025-12-16T07:24:36.000Z
Learnt from: CR
Repo: fastrepl/hyprnote PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-16T07:24:36.000Z
Learning: Applies to **/*.{ts,tsx} : Never do manual state management for form/mutation. Use `useForm` from tanstack-form and `useQuery`/`useMutation` from tanstack-query for 99% cases.

Applied to files:

  • apps/desktop/src/components/onboarding/configure-notice.tsx
🧬 Code graph analysis (1)
apps/desktop/src/components/onboarding/configure-notice.tsx (4)
apps/desktop/src/components/onboarding/config.tsx (1)
  • getNext (17-30)
apps/desktop/src/hooks/useLocalSttModel.ts (1)
  • localSttQueries (22-47)
apps/desktop/src/components/onboarding/shared.tsx (1)
  • OnboardingContainer (6-41)
packages/utils/src/cn.ts (1)
  • cn (20-22)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: desktop_ci (linux, depot-ubuntu-24.04-8)
  • GitHub Check: desktop_ci (linux, depot-ubuntu-22.04-8)
  • GitHub Check: fmt
  • GitHub Check: Redirect rules - hyprnote
  • GitHub Check: Header rules - hyprnote
  • GitHub Check: Pages changed - hyprnote
🔇 Additional comments (9)
apps/desktop/src/components/onboarding/configure-notice.tsx (2)

93-97: Auto-navigation effect may cause unexpected behavior.

The useEffect navigates away immediately if either model is already downloaded. This could be surprising UX if a user wants to download a different model than the one they already have. Consider whether this is the intended behavior, or if users should be allowed to choose even when they have a model downloaded.

Additionally, onNavigate is a callback prop that likely changes on each render. If onNavigate is not memoized by the parent, this effect could trigger unexpectedly.


184-199: Good accessibility implementation.

The interactive div correctly implements role="button", tabIndex={0}, and keyboard event handling for Enter and Space keys. The cn utility is used properly with array grouping for conditional classes.

apps/desktop/src/components/settings/ai/stt/shared.tsx (1)

12-19: Clean re-export pattern for backward compatibility.

The alias export localSttQueries as sttModelQueries maintains backward compatibility for existing consumers while centralizing the query logic in the new hook file. This follows good module organization practices.

apps/desktop/src/components/settings/ai/stt/configure.tsx (2)

5-5: Clean refactor to use shared hook.

The imports are streamlined appropriately - removed useState and useEffect that were previously used for local download state management, and now imports the centralized useLocalModelDownload hook. This follows the DRY principle and aligns with the PR's goal of extracting shared logic.

Also applies to: 22-22, 25-25


307-317: Hook integration looks correct.

The useLocalModelDownload hook is properly integrated, with the useSafeSelectModel callback passed for onDownloadComplete. This ensures the model selection only happens when the listener is inactive, preventing issues during active transcription sessions.

apps/desktop/src/hooks/useLocalSttModel.ts (4)

11-20: Well-structured query key factory.

The localSttKeys factory follows the recommended React Query key-factory pattern, providing consistent and hierarchical cache keys. This enables effective cache invalidation and query deduplication.


96-109: Good guard against duplicate downloads.

The handleDownload function correctly guards against initiating a download when one is already in progress (isDownloaded.data || isDownloading.data || isStarting). The error handling for the download command result is also properly implemented.


69-87: Event listener cleanup pattern is correct.

The effect properly returns a cleanup function that awaits the unlisten promise and calls the returned function. This ensures the listener is removed when the component unmounts or the model changes.


89-94: No action needed. The onDownloadComplete callback passed to this hook is memoized by the caller via useCallback in useSafeSelectModel(), so including it in the dependency array is appropriate and won't cause excessive re-runs.

Comment on lines +99 to +112
const handleUseModel = useCallback(() => {
if (!selectedModel) return;

handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel);
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Download initiated but navigation happens immediately without waiting for completion.

handleUseModel calls downloadModel and then navigates to the next step immediately without awaiting the download result. This means the user is navigated away before knowing if the download started successfully. Consider either:

  1. Waiting for the download command to return before navigating
  2. Or keeping the user on this screen to show download progress (similar to the settings page behavior)

The current approach differs from HyprProviderLocalRow in configure.tsx which shows progress and doesn't navigate away.

🔎 Suggested approach to handle download result
  const handleUseModel = useCallback(() => {
    if (!selectedModel) return;

    handleSelectProvider("hyprnote");
    handleSelectModel(selectedModel);
-   void localSttCommands.downloadModel(selectedModel);
-   onNavigate({ ...search, step: getNext(search) });
+   void localSttCommands.downloadModel(selectedModel).then((result) => {
+     if (result.status === "ok") {
+       onNavigate({ ...search, step: getNext(search) });
+     }
+   });
  }, [
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleUseModel = useCallback(() => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel);
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
const handleUseModel = useCallback(() => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel).then((result) => {
if (result.status === "ok") {
onNavigate({ ...search, step: getNext(search) });
}
});
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
🤖 Prompt for AI Agents
In apps/desktop/src/components/onboarding/configure-notice.tsx around lines 99
to 112, the handler kicks off localSttCommands.downloadModel(selectedModel) but
immediately navigates away; change the callback to an async handler that awaits
the download, sets and uses a loading/progress state, and only calls onNavigate
after a successful download (or shows an error and stays on the page on
failure). Implement try/catch around the await to surface errors (e.g., toast or
inline error state), disable UI controls while downloading, and ensure
dependencies (like localSttCommands) are included in the effect/handler closure.

Comment on lines +99 to +112
const handleUseModel = useCallback(() => {
if (!selectedModel) return;

handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel);
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical bug: The download is started but errors are not handled, and navigation happens immediately without waiting for download to start successfully.

If localSttCommands.downloadModel() returns an error status (e.g., network failure, insufficient disk space), the user will:

  1. Navigate to the next onboarding step
  2. Believe the model is downloading when it actually failed
  3. Complete onboarding with no working STT model

The settings page (configure.tsx, old lines 401-407) properly handles this by checking the result status and setting error state.

Fix:

const handleUseModel = useCallback(async () => {
  if (!selectedModel) return;

  handleSelectProvider("hyprnote");
  handleSelectModel(selectedModel);
  
  const result = await localSttCommands.downloadModel(selectedModel);
  if (result.status === "error") {
    // Handle error - show toast/alert to user
    return;
  }
  
  onNavigate({ ...search, step: getNext(search) });
}, [...]);
Suggested change
const handleUseModel = useCallback(() => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel);
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
const handleUseModel = useCallback(async () => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
const result = await localSttCommands.downloadModel(selectedModel);
if (result.status === "error") {
// Handle error - show toast/alert to user
return;
}
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants