diff --git a/CHANGELOG.md b/CHANGELOG.md
index 226e3a67ee..6d2f0295ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- **Enterprise workspaces can manage local models from first-run setup.** Company admins can publish ordered, approved Dictation and AI/Notes model lists. Employees download one approved model per required category before Permissions, may continue while downloads finish, and complete onboarding after Notes without seeing pricing or provider selection. Managed choices remain locked to the active workspace and automatically move to the first compatible fallback when an admin removes a selection.
- **Uploaded and URL-ingested notes remember their speaker detection.** A note created through Upload ran speaker detection but stored none of it — the note now records that diarization ran, the speaker count you chose, and the audio duration, so it behaves like a meeting note when you record into it or resolve participants. An upload with speaker detection off writes nothing, preserving your global speaker setting. Present since upload speaker detection shipped in 1.7.6. (#1610)
## [1.8.3] - 2026-08-12
diff --git a/main.js b/main.js
index 9dd131e929..006251beaa 100644
--- a/main.js
+++ b/main.js
@@ -534,6 +534,11 @@ function initializeCoreManagers() {
}
function registerSidecars() {
+ if (ipcHandlers) {
+ sidecarRegistry.register("corti-privacy-cleanup", () =>
+ ipcHandlers.retryCortiPrivacyCleanup()
+ );
+ }
if (whisperManager) sidecarRegistry.register("whisper", () => whisperManager.stopServer());
if (parakeetManager) sidecarRegistry.register("parakeet", () => parakeetManager.stopServer());
if (diarizationManager) {
@@ -961,6 +966,9 @@ async function startApp() {
// Phase 1: Core managers + IPC handlers before windows
initializeCoreManagers();
await environmentManager.init();
+ void ipcHandlers.retryCortiPrivacyCleanup().catch((error) => {
+ debugLogger.error("Corti privacy cleanup retry failed", { error: error?.message });
+ });
registerSidecars();
startAuthBridgeServer();
diff --git a/package-lock.json b/package-lock.json
index e1d0b0111e..629f5f1d53 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "open-whispr",
- "version": "1.8.3",
+ "version": "1.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-whispr",
- "version": "1.8.3",
+ "version": "1.8.4",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 0927fb1aa7..f5d94f4dff 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "open-whispr",
- "version": "1.8.3",
+ "version": "1.8.4",
"description": "A desktop dictation application using whisper.cpp for speech-to-text transcription",
"main": "main.js",
"private": true,
diff --git a/preload.js b/preload.js
index bf266965d3..c7d85862e9 100644
--- a/preload.js
+++ b/preload.js
@@ -60,6 +60,11 @@ const registerListener = (channel, handlerFactory) => {
};
};
+const invokeWithRuntimeContext = (channel, args, managedRuntimeContext) =>
+ managedRuntimeContext === undefined
+ ? ipcRenderer.invoke(channel, ...args)
+ : ipcRenderer.invoke(channel, ...args, managedRuntimeContext);
+
contextBridge.exposeInMainWorld("electronAPI", {
setOnboardingWindowMode: (mode) => ipcRenderer.invoke("onboarding-set-window-mode", mode),
setOnboardingActive: (active) => ipcRenderer.invoke("onboarding-set-active", active),
@@ -112,7 +117,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
getAudioStorageUsage: () => ipcRenderer.invoke("get-audio-storage-usage"),
deleteAllAudio: () => ipcRenderer.invoke("delete-all-audio"),
syncRetentionSettings: (settings) => ipcRenderer.send("retention-settings-changed", settings),
- retryTranscription: (id, settings) => ipcRenderer.invoke("retry-transcription", id, settings),
+ retryTranscription: (id, settings, requestId, managedRuntimeContext) =>
+ invokeWithRuntimeContext(
+ "retry-transcription",
+ [id, settings, requestId],
+ managedRuntimeContext
+ ),
+ commitRetryTranscription: (id, requestId, text, rawText) =>
+ ipcRenderer.invoke("commit-retry-transcription", id, requestId, text, rawText),
updateTranscriptionText: (id, text, rawText) =>
ipcRenderer.invoke("update-transcription-text", id, text, rawText),
getTranscriptionById: (id) => ipcRenderer.invoke("get-transcription-by-id", id),
@@ -158,6 +170,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("db-get-notes", noteType, limit, folderId, spaceId),
getSpaceNotes: (spaceId, limit) => ipcRenderer.invoke("db-get-space-notes", spaceId, limit),
updateNote: (id, updates) => ipcRenderer.invoke("db-update-note", id, updates),
+ beginActionNoteCommit: (noteId, context) =>
+ ipcRenderer.invoke("begin-action-note-commit", noteId, context),
+ commitActionNote: (payload) => ipcRenderer.invoke("commit-action-note", payload),
deleteNote: (id) => ipcRenderer.invoke("db-delete-note", id),
exportNote: (noteId, format) => ipcRenderer.invoke("export-note", noteId, format),
exportTranscript: (noteId, format) => ipcRenderer.invoke("export-transcript", noteId, format),
@@ -221,8 +236,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Audio file operations
selectAudioFile: (options) => ipcRenderer.invoke("select-audio-file", options),
getFileSize: (filePath) => ipcRenderer.invoke("get-file-size", filePath),
- transcribeAudioFile: (filePath, options) =>
- ipcRenderer.invoke("transcribe-audio-file", filePath, options),
+ transcribeAudioFile: (filePath, options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("transcribe-audio-file", [filePath, options], managedRuntimeContext),
getPathForFile: (file) => {
const filePath = webUtils.getPathForFile(file);
// Register real dropped-file paths so the main-process audio allowlist accepts them.
@@ -329,8 +344,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
deleteTempAudio: (tempPath) => ipcRenderer.invoke("delete-temp-audio", tempPath),
// Local Whisper functions (whisper.cpp)
- transcribeLocalWhisper: (audioBlob, options) =>
- ipcRenderer.invoke("transcribe-local-whisper", audioBlob, options),
+ transcribeLocalWhisper: (audioBlob, options, managedRuntimeContext) =>
+ invokeWithRuntimeContext(
+ "transcribe-local-whisper",
+ [audioBlob, options],
+ managedRuntimeContext
+ ),
checkWhisperInstallation: () => ipcRenderer.invoke("check-whisper-installation"),
downloadWhisperModel: (modelName) => ipcRenderer.invoke("download-whisper-model", modelName),
onWhisperDownloadProgress: registerListener("whisper-download-progress"),
@@ -385,8 +404,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
dismissGpuPackMigrationNotice: () => ipcRenderer.invoke("dismiss-gpu-pack-migration-notice"),
// Local Parakeet (NVIDIA) functions
- transcribeLocalParakeet: (audioBlob, options) =>
- ipcRenderer.invoke("transcribe-local-parakeet", audioBlob, options),
+ transcribeLocalParakeet: (audioBlob, options, managedRuntimeContext) =>
+ invokeWithRuntimeContext(
+ "transcribe-local-parakeet",
+ [audioBlob, options],
+ managedRuntimeContext
+ ),
checkParakeetInstallation: () => ipcRenderer.invoke("check-parakeet-installation"),
downloadParakeetModel: (modelName) => ipcRenderer.invoke("download-parakeet-model", modelName),
onParakeetDownloadProgress: registerListener("parakeet-download-progress"),
@@ -408,8 +431,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
getDiarizationModelStatus: () => ipcRenderer.invoke("get-diarization-model-status"),
deleteDiarizationModels: () => ipcRenderer.invoke("delete-diarization-models"),
cancelDiarizationDownload: () => ipcRenderer.invoke("cancel-diarization-download"),
- diarizeAudioFile: (filePath, options) =>
- ipcRenderer.invoke("diarize-audio-file", filePath, options),
+ diarizeAudioFile: (filePath, options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("diarize-audio-file", [filePath, options], managedRuntimeContext),
mergeSpeakerText: (segments, text, duration) =>
ipcRenderer.invoke("merge-speaker-text", { segments, text, duration }),
onDiarizationDownloadProgress: registerListener(
@@ -515,17 +538,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
setUiLanguage: (language) => ipcRenderer.invoke("set-ui-language", language),
// xAI / Mistral transcription proxies (keys handled by the manifest bridge)
- proxyXaiTranscription: (data) => ipcRenderer.invoke("proxy-xai-transcription", data),
- proxyMistralTranscription: (data) => ipcRenderer.invoke("proxy-mistral-transcription", data),
+ proxyXaiTranscription: (data, managedRuntimeContext) =>
+ invokeWithRuntimeContext("proxy-xai-transcription", [data], managedRuntimeContext),
+ proxyMistralTranscription: (data, managedRuntimeContext) =>
+ invokeWithRuntimeContext("proxy-mistral-transcription", [data], managedRuntimeContext),
// Corti API
getCortiClientId: () => ipcRenderer.invoke("get-corti-client-id"),
saveCortiClientId: (key) => ipcRenderer.invoke("save-corti-client-id", key),
getCortiClientSecret: () => ipcRenderer.invoke("get-corti-client-secret"),
saveCortiClientSecret: (key) => ipcRenderer.invoke("save-corti-client-secret", key),
- proxyCortiTranscription: (data) => ipcRenderer.invoke("proxy-corti-transcription", data),
+ proxyCortiTranscription: (data, managedRuntimeContext) =>
+ invokeWithRuntimeContext("proxy-corti-transcription", [data], managedRuntimeContext),
getTinfoilChatModels: () => ipcRenderer.invoke("get-tinfoil-chat-models"),
- proxyTinfoilTranscription: (data) => ipcRenderer.invoke("proxy-tinfoil-transcription", data),
+ proxyTinfoilTranscription: (data, managedRuntimeContext) =>
+ invokeWithRuntimeContext("proxy-tinfoil-transcription", [data], managedRuntimeContext),
// Custom endpoint API keys
getCustomTranscriptionKey: () => ipcRenderer.invoke("get-custom-transcription-key"),
@@ -676,7 +703,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
// OpenWhispr Cloud API
cloudHealthCheck: () => ipcRenderer.invoke("cloud-health-check"),
- cloudTranscribe: (audioBuffer, opts) => ipcRenderer.invoke("cloud-transcribe", audioBuffer, opts),
+ cloudTranscribe: (audioBuffer, opts, managedRuntimeContext) =>
+ invokeWithRuntimeContext("cloud-transcribe", [audioBuffer, opts], managedRuntimeContext),
cancelCloudTranscription: () => ipcRenderer.send("cloud-transcribe-cancel"),
cloudReason: (text, opts) => ipcRenderer.invoke("cloud-reason", text, opts),
cancelCloudReason: () => ipcRenderer.send("cloud-reason-cancel"),
@@ -699,11 +727,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
getNoteRecordingConfig: () => ipcRenderer.invoke("get-note-recording-config"),
// Cloud audio file transcription
- transcribeAudioFileCloud: (filePath, options) =>
- ipcRenderer.invoke("transcribe-audio-file-cloud", filePath, options),
+ transcribeAudioFileCloud: (filePath, options, managedRuntimeContext) =>
+ invokeWithRuntimeContext(
+ "transcribe-audio-file-cloud",
+ [filePath, options],
+ managedRuntimeContext
+ ),
cancelUploadTranscription: (requestId) =>
ipcRenderer.invoke("cancel-upload-transcription", requestId),
- transcribeAudioFileByok: (options) => ipcRenderer.invoke("transcribe-audio-file-byok", options),
+ transcribeAudioFileByok: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("transcribe-audio-file-byok", [options], managedRuntimeContext),
onUploadTranscriptionProgress: registerListener(
"upload-transcription-progress",
(callback) => (_event, data) => callback(data)
@@ -715,13 +748,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
getReferralInvites: () => ipcRenderer.invoke("get-referral-invites"),
// Assembly AI Streaming
- assemblyAiStreamingWarmup: (options) =>
- ipcRenderer.invoke("assemblyai-streaming-warmup", options),
- assemblyAiStreamingStart: (options) => ipcRenderer.invoke("assemblyai-streaming-start", options),
- assemblyAiStreamingSend: (audioBuffer) =>
- ipcRenderer.send("assemblyai-streaming-send", audioBuffer),
- assemblyAiStreamingForceEndpoint: () => ipcRenderer.send("assemblyai-streaming-force-endpoint"),
- assemblyAiStreamingStop: () => ipcRenderer.invoke("assemblyai-streaming-stop"),
+ assemblyAiStreamingWarmup: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("assemblyai-streaming-warmup", [options], managedRuntimeContext),
+ assemblyAiStreamingStart: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("assemblyai-streaming-start", [options], managedRuntimeContext),
+ assemblyAiStreamingSend: (transportId, audioBuffer) =>
+ ipcRenderer.send("assemblyai-streaming-send", transportId, audioBuffer),
+ assemblyAiStreamingForceEndpoint: (transportId) =>
+ ipcRenderer.send("assemblyai-streaming-force-endpoint", transportId),
+ assemblyAiStreamingStop: (transportId) =>
+ ipcRenderer.invoke("assemblyai-streaming-stop", transportId),
assemblyAiStreamingStatus: () => ipcRenderer.invoke("assemblyai-streaming-status"),
onAssemblyAiPartialTranscript: registerListener(
"assemblyai-partial-transcript",
@@ -741,11 +777,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
),
// Deepgram Streaming
- deepgramStreamingWarmup: (options) => ipcRenderer.invoke("deepgram-streaming-warmup", options),
- deepgramStreamingStart: (options) => ipcRenderer.invoke("deepgram-streaming-start", options),
- deepgramStreamingSend: (audioBuffer) => ipcRenderer.send("deepgram-streaming-send", audioBuffer),
- deepgramStreamingFinalize: () => ipcRenderer.send("deepgram-streaming-finalize"),
- deepgramStreamingStop: () => ipcRenderer.invoke("deepgram-streaming-stop"),
+ deepgramStreamingWarmup: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("deepgram-streaming-warmup", [options], managedRuntimeContext),
+ deepgramStreamingStart: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("deepgram-streaming-start", [options], managedRuntimeContext),
+ deepgramStreamingSend: (transportId, audioBuffer) =>
+ ipcRenderer.send("deepgram-streaming-send", transportId, audioBuffer),
+ deepgramStreamingFinalize: (transportId) =>
+ ipcRenderer.send("deepgram-streaming-finalize", transportId),
+ deepgramStreamingStop: (transportId) =>
+ ipcRenderer.invoke("deepgram-streaming-stop", transportId),
deepgramStreamingStatus: () => ipcRenderer.invoke("deepgram-streaming-status"),
onDeepgramPartialTranscript: registerListener(
"deepgram-partial-transcript",
@@ -765,11 +806,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
),
// Corti streaming (BYOK)
- cortiStreamingWarmup: (options) => ipcRenderer.invoke("corti-streaming-warmup", options),
- cortiStreamingStart: (options) => ipcRenderer.invoke("corti-streaming-start", options),
- cortiStreamingSend: (audioBuffer) => ipcRenderer.send("corti-streaming-send", audioBuffer),
- cortiStreamingFinalize: () => ipcRenderer.send("corti-streaming-finalize"),
- cortiStreamingStop: () => ipcRenderer.invoke("corti-streaming-stop"),
+ cortiStreamingWarmup: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("corti-streaming-warmup", [options], managedRuntimeContext),
+ cortiStreamingStart: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("corti-streaming-start", [options], managedRuntimeContext),
+ cortiStreamingSend: (transportId, audioBuffer) =>
+ ipcRenderer.send("corti-streaming-send", transportId, audioBuffer),
+ cortiStreamingFinalize: (transportId) =>
+ ipcRenderer.send("corti-streaming-finalize", transportId),
+ cortiStreamingStop: (transportId) => ipcRenderer.invoke("corti-streaming-stop", transportId),
cortiStreamingStatus: () => ipcRenderer.invoke("corti-streaming-status"),
onCortiPartialTranscript: registerListener(
"corti-partial-transcript",
@@ -786,17 +831,21 @@ contextBridge.exposeInMainWorld("electronAPI", {
),
// Meeting transcription (streaming, dual-channel)
- meetingTranscriptionPrepare: (options) =>
- ipcRenderer.invoke("meeting-transcription-prepare", options),
- meetingTranscriptionStart: (options) =>
- ipcRenderer.invoke("meeting-transcription-start", options),
- meetingTranscriptionSend: (buffer, source) =>
- ipcRenderer.send("meeting-transcription-send", buffer, source),
+ meetingTranscriptionPrepare: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("meeting-transcription-prepare", [options], managedRuntimeContext),
+ meetingTranscriptionStart: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("meeting-transcription-start", [options], managedRuntimeContext),
+ meetingTranscriptionSend: (sessionId, buffer, source) =>
+ ipcRenderer.send("meeting-transcription-send", sessionId, buffer, source),
meetingTranscriptionSetSystemAudioAvailable: (sessionId, available) =>
ipcRenderer.invoke("meeting-transcription-set-system-audio-available", sessionId, available),
meetingTranscriptionStop: (expectedSessionId) =>
ipcRenderer.invoke("meeting-transcription-stop", expectedSessionId),
- meetingTranscriptionCancel: () => ipcRenderer.invoke("meeting-transcription-cancel"),
+ meetingTranscriptionAbort: (expectedSessionId) =>
+ ipcRenderer.invoke("meeting-transcription-abort", expectedSessionId),
+ commitMeetingTranscript: (payload) => ipcRenderer.invoke("meeting-transcription-commit", payload),
+ meetingTranscriptionCancel: (expectedTransportId) =>
+ ipcRenderer.invoke("meeting-transcription-cancel", expectedTransportId),
onMeetingTranscriptionSegment: registerListener(
"meeting-transcription-segment",
(callback) => (_event, data) => callback(data)
@@ -823,10 +872,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
),
// Dictation realtime streaming
- dictationRealtimeWarmup: (options) => ipcRenderer.invoke("dictation-realtime-warmup", options),
- dictationRealtimeStart: (options) => ipcRenderer.invoke("dictation-realtime-start", options),
- dictationRealtimeSend: (buffer) => ipcRenderer.send("dictation-realtime-send", buffer),
- dictationRealtimeStop: () => ipcRenderer.invoke("dictation-realtime-stop"),
+ dictationRealtimeWarmup: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("dictation-realtime-warmup", [options], managedRuntimeContext),
+ dictationRealtimeStart: (options, managedRuntimeContext) =>
+ invokeWithRuntimeContext("dictation-realtime-start", [options], managedRuntimeContext),
+ dictationRealtimeSend: (transportId, buffer) =>
+ ipcRenderer.send("dictation-realtime-send", transportId, buffer),
+ dictationRealtimeStop: (transportId) =>
+ ipcRenderer.invoke("dictation-realtime-stop", transportId),
+ dictationStreamingAbort: (transportId) =>
+ ipcRenderer.invoke("dictation-streaming-abort", transportId),
onDictationRealtimePartial: registerListener(
"dictation-realtime-partial",
(callback) => (_event, data) => callback(data)
@@ -948,13 +1003,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
(callback) => (_event, payload) => callback(payload)
),
onPreviewHide: registerListener("preview-hide", (callback) => () => callback()),
- startDictationPreview: (opts) => ipcRenderer.invoke("start-dictation-preview", opts),
+ startDictationPreview: (opts, managedRuntimeContext) =>
+ invokeWithRuntimeContext("start-dictation-preview", [opts], managedRuntimeContext),
stopDictationPreview: (opts) => ipcRenderer.invoke("stop-dictation-preview", opts),
dismissDictationPreview: () => ipcRenderer.invoke("dismiss-dictation-preview"),
updateDictationPreview: (text) => ipcRenderer.invoke("update-dictation-preview", text),
completeDictationPreview: (payload) => ipcRenderer.invoke("complete-dictation-preview", payload),
hideDictationPreview: () => ipcRenderer.invoke("hide-dictation-preview"),
- sendDictationPreviewAudio: (data) => ipcRenderer.send("dictation-preview-audio", data),
+ sendDictationPreviewAudio: (transportId, data) =>
+ ipcRenderer.send("dictation-preview-audio", transportId, data),
acquireRecordingLock: (pipeline) => ipcRenderer.invoke("acquire-recording-lock", pipeline),
releaseRecordingLock: (pipeline) => ipcRenderer.invoke("release-recording-lock", pipeline),
diff --git a/src/AppRouter.jsx b/src/AppRouter.jsx
index cad94f05b4..bb3b985611 100644
--- a/src/AppRouter.jsx
+++ b/src/AppRouter.jsx
@@ -6,11 +6,14 @@ import MeetingNotificationOverlay from "./components/MeetingNotificationOverlay.
import UpdateNotificationOverlay from "./components/UpdateNotificationOverlay.tsx";
import WindowControls from "./components/WindowControls.tsx";
import BackgroundModelDownloadTray from "./components/onboarding/BackgroundModelDownloadTray.tsx";
+import ManagedEnterpriseModelCoordinator from "./components/onboarding/ManagedEnterpriseModelCoordinator.tsx";
import { Card, CardContent } from "./components/ui/card.tsx";
import { LEGACY_ONBOARDING_STEP_KEY, ONBOARDING_SESSION_KEY } from "./components/onboarding/flow";
import { useAuth } from "./hooks/useAuth";
import { useTheme } from "./hooks/useTheme";
import { usePolicyStore } from "./stores/policyStore";
+import { useEnterpriseIdentityStore } from "./stores/enterpriseIdentityStore";
+import { isEnterpriseInferenceReady } from "./helpers/enterpriseInferenceReadiness";
import { isControlPanelWindow } from "./utils/windowContext.ts";
// Either marker means the flow is mid-way: the legacy step key is kept for
@@ -40,12 +43,21 @@ export default function AppRouter() {
function MainApp() {
const { isSignedIn, isGracePeriodOnly, isLoaded: authLoaded } = useAuth();
const policyStatus = usePolicyStore((state) => state.status);
+ const enterpriseStatus = useEnterpriseIdentityStore((state) => state.status);
+ const enterpriseFailClosed = useEnterpriseIdentityStore((state) => state.failClosed);
const policyResolved =
!isSignedIn ||
policyStatus === "managed" ||
policyStatus === "unmanaged" ||
policyStatus === "error";
- const isWaitingForPolicyStart = isSignedIn && !policyResolved;
+ const enterpriseInferenceReady = isEnterpriseInferenceReady({
+ authLoaded,
+ policyResolved,
+ isSignedIn,
+ enterpriseStatus,
+ enterpriseFailClosed,
+ });
+ const isWaitingForPolicyStart = !enterpriseInferenceReady;
const autoSyncReady = authLoaded && policyResolved;
const [showOnboarding, setShowOnboarding] = useState(false);
@@ -132,7 +144,10 @@ function MainApp() {
}, [isControlPanel, isLoading, isWaitingForPolicyStart, showOnboarding]);
useEffect(() => {
- if (isLoading || isWaitingForPolicyStart) return;
+ if (isLoading || isWaitingForPolicyStart) {
+ void window.electronAPI?.setOnboardingActive?.(true);
+ return;
+ }
const onboardingCompleted = localStorage.getItem("onboardingCompleted") === "true";
const normalAppVisible = onboardingCompleted && (!isControlPanel || !showOnboarding);
@@ -208,9 +223,13 @@ function MainApp() {
}>
+
) : (
-
+ <>
+
+
+ >
);
}
diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx
index 5876e5a0de..aa7e9fc000 100644
--- a/src/components/ControlPanel.tsx
+++ b/src/components/ControlPanel.tsx
@@ -23,6 +23,7 @@ import { useSettings } from "../hooks/useSettings";
import { useAuth } from "../hooks/useAuth";
import { useJoinableWorkspaces } from "../hooks/useJoinableWorkspaces";
import { useUsage } from "../hooks/useUsage";
+import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock";
import { decideUpsell } from "../lib/upsell";
import { useCollapsibleSidebar } from "../hooks/useCollapsibleSidebar";
import {
@@ -44,7 +45,6 @@ import {
isAgentAllowed,
isControlPanelViewAllowed,
isPolicyActionAllowed,
- isTranscriptionContextAllowed,
isUpdateRequiredByOrg,
} from "../stores/policyRules";
import {
@@ -85,6 +85,17 @@ import {
consumePendingInvitationToken,
clearPendingInvitationToken,
} from "../utils/pendingInvitationToken";
+import { useEnterpriseIdentityStore } from "../stores/enterpriseIdentityStore";
+import { useWorkspaceStore } from "../stores/workspaceStore";
+import { canApplyPendingCloudMigration } from "./onboarding/managedLocalModels";
+import {
+ captureManagedRuntimeAuthorizationContext,
+ isManagedLocalTranscriptionRuntimeAllowed,
+ resolveManagedLocalTranscriptionRuntime,
+} from "../helpers/managedLocalTranscriptionRuntime";
+import { captureRuntimeAuthorizationLease } from "../helpers/runtimeAuthorizationBoundary";
+import { resolveTranscriptionRoute } from "../helpers/transcriptionRoute";
+import { getTranscriptionProviders } from "../models/ModelRegistry";
const platform = getCachedPlatform();
@@ -113,6 +124,13 @@ interface ControlPanelProps {
export default function ControlPanel({ initialSettingsSection }: ControlPanelProps = {}) {
const { t } = useTranslation();
+ const managedTranscriptionLock = useManagedLocalModelLock("transcription");
+ const enterpriseIdentityStatus = useEnterpriseIdentityStore((state) => state.status);
+ const enterpriseIdentityFailClosed = useEnterpriseIdentityStore((state) => state.failClosed);
+ const workspaceIdentityResolved = useWorkspaceStore(
+ (state) => state.loaded && !state.loading && !state.error
+ );
+ const enterpriseIdentityExpected = useWorkspaceStore((state) => state.activeWorkspaceId !== null);
const history = useTranscriptions();
const [isLoading, setIsLoading] = useState(true);
const [showSettings, setShowSettings] = useState(!!initialSettingsSection);
@@ -398,13 +416,34 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
const isPending = localStorage.getItem("pendingCloudMigration") === "true";
const alreadyShown = localStorage.getItem("cloudMigrationShown") === "true";
if (!isPending || alreadyShown) return;
+ if (
+ !canApplyPendingCloudMigration(
+ enterpriseIdentityStatus,
+ enterpriseIdentityFailClosed,
+ managedTranscriptionLock.managed,
+ workspaceIdentityResolved,
+ enterpriseIdentityExpected
+ )
+ ) {
+ return;
+ }
cloudMigrationProcessed.current = true;
setUseLocalWhisper(false);
setCloudTranscriptionMode("openwhispr");
localStorage.removeItem("pendingCloudMigration");
setShowCloudMigrationBanner(true);
- }, [authLoaded, isSignedIn, setUseLocalWhisper, setCloudTranscriptionMode]);
+ }, [
+ authLoaded,
+ enterpriseIdentityExpected,
+ enterpriseIdentityFailClosed,
+ enterpriseIdentityStatus,
+ isSignedIn,
+ managedTranscriptionLock.managed,
+ setUseLocalWhisper,
+ setCloudTranscriptionMode,
+ workspaceIdentityResolved,
+ ]);
useEffect(() => {
if (platform === "darwin" || gpuBannerDismissed) return;
@@ -626,30 +665,85 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
const retryTranscription = useCallback(
async (id: number, options?: { isRecover?: boolean }) => {
+ const requestId = crypto.randomUUID();
+ let pendingCommit = false;
+ const authorization = captureRuntimeAuthorizationLease(["reasoning", "transcription"], () => {
+ void window.electronAPI.cancelUploadTranscription?.(requestId);
+ });
try {
+ authorization.assertCurrent();
const s = getSettings();
- if (!isTranscriptionContextAllowed(usePolicyStore.getState(), s, "dictation")) {
+ const runtime = resolveManagedLocalTranscriptionRuntime(s);
+ if (!isManagedLocalTranscriptionRuntimeAllowed(runtime, usePolicyStore.getState())) {
toast({ title: t("common.managedByOrg"), variant: "default" });
return;
}
- const result = await window.electronAPI.retryTranscription(id, {
- useLocalWhisper: s.useLocalWhisper,
- localTranscriptionProvider: s.localTranscriptionProvider,
- cloudTranscriptionMode: s.cloudTranscriptionMode,
- cloudTranscriptionProvider: s.cloudTranscriptionProvider,
- cloudTranscriptionModel: s.cloudTranscriptionModel,
- cloudTranscriptionBaseUrl: s.cloudTranscriptionBaseUrl,
- cortiEnvironment: s.cortiEnvironment,
- cortiTenant: s.cortiTenant,
- parakeetModel: s.parakeetModel,
- whisperModel: s.whisperModel,
- preferredLanguage: s.preferredLanguage,
- transcriptionMode: s.transcriptionMode,
- remoteTranscriptionType: s.remoteTranscriptionType,
- remoteTranscriptionUrl: s.remoteTranscriptionUrl,
- remoteTranscriptionModel: s.remoteTranscriptionModel,
+ if (runtime.kind === "error") return;
+ const effectiveSettings = runtime.settings;
+ const route = resolveTranscriptionRoute({
+ settings: effectiveSettings,
+ providers: getTranscriptionProviders(),
+ request: {
+ effectiveLanguage:
+ effectiveSettings.preferredLanguage === "auto"
+ ? undefined
+ : effectiveSettings.preferredLanguage,
+ },
});
- if (result.success && result.transcription) {
+ const contextRoute = effectiveSettings.useLocalWhisper
+ ? {
+ transcriptionMode: "local" as const,
+ provider: effectiveSettings.localTranscriptionProvider,
+ model:
+ effectiveSettings.localTranscriptionProvider === "nvidia"
+ ? effectiveSettings.parakeetModel
+ : effectiveSettings.whisperModel,
+ }
+ : effectiveSettings.cloudTranscriptionMode === "openwhispr"
+ ? { transcriptionMode: "openwhispr" as const, provider: "openwhispr", model: null }
+ : route.transport === "proxied" || route.transport === "http-batch"
+ ? {
+ transcriptionMode:
+ route.provider === "self-hosted"
+ ? ("self-hosted" as const)
+ : ("providers" as const),
+ provider: route.provider,
+ model: route.model,
+ }
+ : {
+ transcriptionMode: effectiveSettings.transcriptionMode,
+ provider: effectiveSettings.cloudTranscriptionProvider || "openai",
+ model: effectiveSettings.cloudTranscriptionModel || null,
+ };
+ authorization.assertCurrent();
+ const result = await window.electronAPI.retryTranscription(
+ id,
+ {
+ useLocalWhisper: effectiveSettings.useLocalWhisper,
+ localTranscriptionProvider: effectiveSettings.localTranscriptionProvider,
+ cloudTranscriptionMode: effectiveSettings.cloudTranscriptionMode,
+ cloudTranscriptionProvider: effectiveSettings.cloudTranscriptionProvider,
+ cloudTranscriptionModel: effectiveSettings.cloudTranscriptionModel,
+ cloudTranscriptionBaseUrl: effectiveSettings.cloudTranscriptionBaseUrl,
+ cortiEnvironment: effectiveSettings.cortiEnvironment,
+ cortiTenant: effectiveSettings.cortiTenant,
+ parakeetModel: effectiveSettings.parakeetModel,
+ whisperModel: effectiveSettings.whisperModel,
+ preferredLanguage: effectiveSettings.preferredLanguage,
+ transcriptionMode: effectiveSettings.transcriptionMode,
+ remoteTranscriptionType: effectiveSettings.remoteTranscriptionType,
+ remoteTranscriptionUrl: effectiveSettings.remoteTranscriptionUrl,
+ remoteTranscriptionModel: effectiveSettings.remoteTranscriptionModel,
+ },
+ requestId,
+ captureManagedRuntimeAuthorizationContext({
+ managed: runtime.managed,
+ ...contextRoute,
+ })
+ );
+ pendingCommit = result.success && result.pendingCommit === true;
+ authorization.assertCurrent();
+ if (result.success && pendingCommit && result.transcription) {
const rawText = result.transcription.text;
let finalTranscription = result.transcription;
@@ -668,6 +762,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
import("../helpers/audioManager"),
import("../stores/settingsStore"),
]);
+ authorization.assertCurrent();
const settings = getEffectiveSettings();
const agentName = localStorage.getItem("agentName") || null;
const route = resolveReasoningRoute(rawText, settings, agentName, false, true);
@@ -675,15 +770,24 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
const { text, translated } = await executeTranslationChain({
text: rawText,
cleanupReachable: route.cleanupReachable,
- runCleanup: (currentText: string) =>
- ReasoningService.processText(
+ runCleanup: (currentText: string) => {
+ authorization.assertCurrent();
+ return ReasoningService.processText(
currentText,
getEffectiveCleanupModel(),
agentName,
route.cleanupConfig
- ),
- runTranslate: (currentText: string) =>
- ReasoningService.processText(currentText, route.model, agentName, route.config),
+ );
+ },
+ runTranslate: (currentText: string) => {
+ authorization.assertCurrent();
+ return ReasoningService.processText(
+ currentText,
+ route.model,
+ agentName,
+ route.config
+ );
+ },
shouldTranslate: shouldRunTranslateStep(
settings.translationSourceLanguage,
settings.translationTargetLanguage
@@ -707,22 +811,20 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
"transcription"
),
});
+ authorization.assertCurrent();
translationApplied = translated;
if (text !== rawText) {
- const updated = await window.electronAPI.updateTranscriptionText(
- id,
- text,
- rawText
- );
- if (updated.success && updated.transcription) {
- finalTranscription = updated.transcription;
- }
+ authorization.assertCurrent();
+ finalTranscription = { ...finalTranscription, text };
}
} else {
// Translation disabled/unreachable since recording — fall through to cleanup.
handledTranslation = false;
}
- } catch {
+ } catch (error) {
+ if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") {
+ throw error;
+ }
// Reasoning failed — keep the raw STT result
}
}
@@ -737,25 +839,25 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
import("../services/ReasoningService"),
import("../stores/settingsStore"),
]);
+ authorization.assertCurrent();
const model = getEffectiveCleanupModel();
const isCloud = isCloudCleanupMode();
if (model || isCloud) {
const agentName = localStorage.getItem("agentName") || null;
+ authorization.assertCurrent();
const reasonedText = await ReasoningService.processText(rawText, model, agentName, {
disableThinking: getSettings().cleanupDisableThinking,
});
+ authorization.assertCurrent();
if (hasTextContent(reasonedText) && reasonedText !== rawText) {
- const updated = await window.electronAPI.updateTranscriptionText(
- id,
- reasonedText,
- rawText
- );
- if (updated.success && updated.transcription) {
- finalTranscription = updated.transcription;
- }
+ authorization.assertCurrent();
+ finalTranscription = { ...finalTranscription, text: reasonedText };
}
}
- } catch {
+ } catch (error) {
+ if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") {
+ throw error;
+ }
// Reasoning failed — keep the raw STT result
}
}
@@ -766,6 +868,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
// translate step moves the text into the target language, so anything else
// still has to be scripted as the language that was dictated.
try {
+ authorization.assertCurrent();
const outputLanguage =
result.transcription.route_kind === "translation"
? (translationApplied
@@ -780,21 +883,36 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
finalTranscription.text
)
);
+ authorization.assertCurrent();
if (scripted !== finalTranscription.text) {
- const updated = await window.electronAPI.updateTranscriptionText(
- id,
- scripted,
- rawText
- );
- if (updated.success && updated.transcription) {
- finalTranscription = updated.transcription;
- }
+ authorization.assertCurrent();
+ finalTranscription = { ...finalTranscription, text: scripted };
+ }
+ } catch (error) {
+ if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") {
+ throw error;
}
- } catch {
// Conversion failed — keep the text as transcribed
}
- updateInStore(finalTranscription);
+ authorization.assertCurrent();
+ const committed = await window.electronAPI.commitRetryTranscription(
+ id,
+ requestId,
+ finalTranscription.text,
+ rawText
+ );
+ pendingCommit = false;
+ authorization.assertCurrent();
+ if (!committed.success || !committed.transcription) {
+ toast({
+ title: t("controlPanel.history.retryError"),
+ description: committed.error,
+ variant: "destructive",
+ });
+ return;
+ }
+ updateInStore(committed.transcription);
toast({
title: t(
options?.isRecover
@@ -809,11 +927,17 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
variant: "destructive",
});
}
- } catch {
+ } catch (error) {
+ if ((error as { code?: string }).code === "AUTHORIZATION_BOUNDARY_CHANGED") return;
toast({
title: t("controlPanel.history.retryError"),
variant: "destructive",
});
+ } finally {
+ authorization.dispose();
+ if (pendingCommit) {
+ void window.electronAPI.cancelUploadTranscription?.(requestId);
+ }
}
},
[toast, t, useCleanupModel]
diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx
index 8b440a5ffb..d38bb5d0ff 100644
--- a/src/components/OnboardingFlow.tsx
+++ b/src/components/OnboardingFlow.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { AlertCircle } from "lucide-react";
+import { AlertCircle, Loader2 } from "lucide-react";
import AuthenticationStep from "./AuthenticationStep";
import EmailVerificationStep from "./EmailVerificationStep";
import UseCaseStep from "./onboarding/UseCaseStep";
@@ -15,6 +15,7 @@ import CalendarConnectionsStep from "./onboarding/CalendarConnectionsStep";
import SetupChoiceStep from "./onboarding/SetupChoiceStep";
import { ByokProviderStep, LocalModelSetupStep } from "./onboarding/ProviderSetupStep";
import { AlertDialog } from "./ui/dialog";
+import { Button } from "./ui/button";
import { useAuth } from "../hooks/useAuth";
import { signOut } from "../lib/auth";
import { usePermissions } from "../hooks/usePermissions";
@@ -51,6 +52,21 @@ import { useOnboardingSession } from "./onboarding/useOnboardingSession";
import { clearPendingLocalModels, hasPendingLocalModels } from "./onboarding/pendingLocalModels";
import { ActivationModeSelector } from "./ui/ActivationModeSelector";
import LinuxPttSetupInfo from "./ui/LinuxPttSetupInfo";
+import EnterpriseModelSetupStep from "./onboarding/EnterpriseModelSetupStep";
+import {
+ EnterpriseConfigErrorActions,
+ ManagedSetupSignOutButton,
+} from "./onboarding/ManagedSetupBlockedActions";
+import {
+ selectEffectiveManagedLocalModels,
+ useEnterpriseIdentityStore,
+} from "../stores/enterpriseIdentityStore";
+import {
+ areManagedLocalModelBindingsReady,
+ readManagedLocalModelBinding,
+ requiresManagedLocalModels,
+ translateManagedLocalModelError,
+} from "./onboarding/managedLocalModels";
interface OnboardingFlowProps {
onComplete: (options?: { openSettings?: boolean }) => void;
@@ -143,6 +159,26 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
setupMode: session.setupMode,
activeWorkspace: enterpriseWorkspace,
});
+ const enterpriseIdentity = useEnterpriseIdentityStore();
+ const enterpriseLocalModels =
+ enterpriseIdentity.workspaceId === enterpriseWorkspace?.id
+ ? selectEffectiveManagedLocalModels(enterpriseIdentity)
+ : null;
+ const requiresEnterpriseModels = requiresManagedLocalModels(enterpriseLocalModels);
+ const enterpriseConfigPending = Boolean(
+ enterpriseWorkspace &&
+ (enterpriseIdentity.workspaceId !== enterpriseWorkspace.id ||
+ enterpriseIdentity.status === "idle" ||
+ enterpriseIdentity.status === "loading")
+ );
+ const enterpriseConfigError =
+ enterpriseWorkspace &&
+ enterpriseIdentity.workspaceId === enterpriseWorkspace.id &&
+ enterpriseIdentity.status === "error"
+ ? enterpriseIdentity.error
+ ? translateManagedLocalModelError(enterpriseIdentity.error, t)
+ : t("managedLocalModels.onboarding.configUnavailableFallback")
+ : null;
useEffect(() => {
if (
@@ -165,7 +201,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
isSignedIn &&
session.authPath === "account" &&
(!workspacesLoaded ||
- (!activeWorkspace && skipSetupChoiceForEnterprise && Boolean(enterpriseWorkspace)));
+ (!activeWorkspace && skipSetupChoiceForEnterprise && Boolean(enterpriseWorkspace)) ||
+ enterpriseConfigPending);
const route = useMemo(
() =>
@@ -174,8 +211,15 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
setupMode: session.setupMode,
agentAllowed,
skipSetupChoice: skipSetupChoiceForEnterprise,
+ requiresEnterpriseModels,
}),
- [agentAllowed, session.authPath, session.setupMode, skipSetupChoiceForEnterprise]
+ [
+ agentAllowed,
+ requiresEnterpriseModels,
+ session.authPath,
+ session.setupMode,
+ skipSetupChoiceForEnterprise,
+ ]
);
const currentStepId = reconcileStepWithRoute(session.currentStepId, route);
const compact = COMPACT_STEPS.has(currentStepId);
@@ -186,6 +230,84 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
}
}, [currentStepId, session.currentStepId, setSession]);
+ // Authentication is not complete for onboarding until both workspace
+ // membership and the active Enterprise configuration have resolved. This
+ // keeps a slow or failed configuration request from silently skipping the
+ // managed-model step.
+ useEffect(() => {
+ if (
+ !isSignedIn ||
+ session.authPath !== "account" ||
+ session.currentStepId !== "auth" ||
+ !workspacesLoaded
+ ) {
+ return;
+ }
+ if (enterpriseWorkspace && activeWorkspace?.id !== enterpriseWorkspace.id) {
+ setActiveWorkspace(enterpriseWorkspace.id);
+ return;
+ }
+ if (enterpriseWorkspace && (enterpriseConfigPending || enterpriseConfigError)) return;
+ goTo(
+ enterpriseWorkspace
+ ? requiresEnterpriseModels
+ ? "enterprise-models"
+ : "permissions"
+ : session.setupMode === "cloud"
+ ? "setup-choice"
+ : "permissions"
+ );
+ }, [
+ activeWorkspace?.id,
+ enterpriseConfigError,
+ enterpriseConfigPending,
+ enterpriseWorkspace,
+ goTo,
+ isSignedIn,
+ requiresEnterpriseModels,
+ session.authPath,
+ session.currentStepId,
+ session.setupMode,
+ setActiveWorkspace,
+ workspacesLoaded,
+ ]);
+
+ const [, setManagedBindingRevision] = useState(0);
+ useEffect(() => {
+ const refresh = () => setManagedBindingRevision((revision) => revision + 1);
+ window.addEventListener("openwhispr-managed-local-model-binding", refresh);
+ return () => window.removeEventListener("openwhispr-managed-local-model-binding", refresh);
+ }, []);
+ const managedBinding =
+ enterpriseIdentity.accountId && enterpriseWorkspace
+ ? readManagedLocalModelBinding(enterpriseIdentity.accountId, enterpriseWorkspace.id)
+ : null;
+ const requiredManagedBindingsReady = areManagedLocalModelBindingsReady(
+ enterpriseLocalModels,
+ managedBinding
+ );
+
+ useEffect(() => {
+ if (
+ !requiresEnterpriseModels ||
+ requiredManagedBindingsReady ||
+ currentStepId === "auth" ||
+ currentStepId === "enterprise-models" ||
+ enterpriseConfigPending ||
+ enterpriseConfigError
+ ) {
+ return;
+ }
+ goTo("enterprise-models");
+ }, [
+ currentStepId,
+ enterpriseConfigError,
+ enterpriseConfigPending,
+ goTo,
+ requiredManagedBindingsReady,
+ requiresEnterpriseModels,
+ ]);
+
// AppRouter releases this only after it has committed the normal app. Keeping
// the gate active across this component's unmount prevents a one-frame flash
// of the dictation pill or another normal-app overlay at completion/error.
@@ -334,7 +456,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
// Only preserve a pending download when the completed route still uses
// local models. A user who walks Back and finishes on Cloud/BYOK must not
// be switched back to a stale local selection when it completes later.
- const routeKeepsLocalModels = mode === "local";
+ const routeKeepsLocalModels =
+ mode === "local" || (mode === "managed" && requiresEnterpriseModels);
if (routeKeepsLocalModels && (options.localPending || hasPendingLocalModels())) {
localStorage.setItem("localSetupPending", "true");
} else {
@@ -364,6 +487,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
session.authPath,
t,
withExtraDictationHotkeys,
+ requiresEnterpriseModels,
]
);
@@ -371,11 +495,23 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
// Enterprise workspace is confirmed. Finish them without writing provider or
// model settings, just as if Notes had been their final step originally.
useEffect(() => {
- if (!skipSetupChoiceForEnterprise || session.currentStepId !== "setup-choice" || isFinishing) {
+ if (!skipSetupChoiceForEnterprise || session.currentStepId !== "setup-choice") {
return;
}
+ if (requiresEnterpriseModels) {
+ goTo("enterprise-models");
+ return;
+ }
+ if (isFinishing) return;
void finalizeOnboarding("managed");
- }, [finalizeOnboarding, isFinishing, session.currentStepId, skipSetupChoiceForEnterprise]);
+ }, [
+ finalizeOnboarding,
+ goTo,
+ isFinishing,
+ requiresEnterpriseModels,
+ session.currentStepId,
+ skipSetupChoiceForEnterprise,
+ ]);
const applyReasoningSelectionToAllScopes = useCallback(
(mode: "byok" | "local") => {
@@ -442,7 +578,15 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const continueFromCurrentStep = useCallback(async () => {
// A banner from an earlier failed attempt must not outlive the retry.
setFatalError(null);
- if (currentStepId === "notes" && workspaceResolutionPending) return;
+ if (
+ currentStepId === "notes" &&
+ (workspaceResolutionPending || !requiredManagedBindingsReady)
+ ) {
+ setFatalError(
+ !requiredManagedBindingsReady ? t("managedLocalModels.onboarding.restartDownload") : null
+ );
+ return;
+ }
if (currentStepId === "permissions") {
if (getPlatform() === "darwin" && !permissions.accessibilityPermissionGranted) {
setAccessibilitySkipped(true);
@@ -526,6 +670,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
withExtraDictationHotkeys,
workspaceResolutionPending,
skipSetupChoiceForEnterprise,
+ requiredManagedBindingsReady,
]);
const skipLocalSetup = useCallback(async () => {
@@ -555,7 +700,9 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
case "assistant-demo":
return assistantDemoSuccess;
case "notes":
- return !workspaceResolutionPending;
+ return !workspaceResolutionPending && requiredManagedBindingsReady;
+ case "enterprise-models":
+ return stageReady;
case "byok-dictation":
case "byok-assistant":
case "local-dictation":
@@ -569,6 +716,47 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const renderStep = () => {
switch (currentStepId) {
case "auth":
+ if (isSignedIn && session.authPath === "account") {
+ return (
+
+ {enterpriseConfigError ? (
+
+
+
+
+ {t("managedLocalModels.onboarding.unavailableTitle")}
+
+
{enterpriseConfigError}
+
+ {t("managedLocalModels.onboarding.unavailableInstructions")}
+
+
+
{
+ if (
+ enterpriseIdentity.accountId &&
+ enterpriseIdentity.workspaceId &&
+ enterpriseIdentity.authGeneration != null
+ ) {
+ void enterpriseIdentity.refresh(
+ enterpriseIdentity.accountId,
+ enterpriseIdentity.workspaceId,
+ enterpriseIdentity.authGeneration,
+ true
+ );
+ }
+ }}
+ />
+
+ ) : (
+
+
+ {t("managedLocalModels.onboarding.checking")}
+
+ )}
+
+ );
+ }
return (
{pendingVerificationEmail ? (
@@ -577,7 +765,6 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
onVerified={() => {
setPendingVerificationEmail(null);
setAuthPath("account");
- goTo(session.setupMode === "cloud" ? "setup-choice" : "permissions");
}}
onBack={() => {
// Abandoning verification leaves a live session for the
@@ -597,7 +784,6 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
}}
onAuthComplete={() => {
setAuthPath("account");
- goTo(session.setupMode === "cloud" ? "setup-choice" : "permissions");
}}
onNeedsVerification={setPendingVerificationEmail}
/>
@@ -605,6 +791,36 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
);
+ case "enterprise-models":
+ if (
+ !enterpriseLocalModels ||
+ !enterpriseIdentity.accountId ||
+ !enterpriseIdentity.workspaceId ||
+ enterpriseIdentity.authGeneration == null
+ ) {
+ return null;
+ }
+ return (
+
+
+
+
+ );
+
case "permissions":
return (
: undefined}
// History is the only Back gate. This preserves the branch's provider
// escape path and also lets users return from setup choice/languages.
onBack={hasShellNavigation && session.history.length > 0 ? goBack : undefined}
diff --git a/src/components/ReasoningModelSelector.tsx b/src/components/ReasoningModelSelector.tsx
index 6cd9a57166..19b6d3e94f 100644
--- a/src/components/ReasoningModelSelector.tsx
+++ b/src/components/ReasoningModelSelector.tsx
@@ -33,6 +33,8 @@ import {
reconcileProviderSelection,
} from "../stores/policyRules";
import { usePolicySnapshot } from "../hooks/usePolicy";
+import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock";
+import { ManagedLocalModelNotice } from "./settings/ManagedLocalModelNotice";
type CloudModelOption = {
value: string;
@@ -322,7 +324,7 @@ function GpuStatusBadge() {
return null;
}
-export default function ReasoningModelSelector({
+function ReasoningModelSelectorContent({
reasoningModel,
setReasoningModel,
localReasoningProvider,
@@ -723,3 +725,9 @@ export default function ReasoningModelSelector({
);
}
+
+export default function ReasoningModelSelector(props: ReasoningModelSelectorProps) {
+ const lock = useManagedLocalModelLock("reasoning");
+ if (lock.managed) return ;
+ return ;
+}
diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx
index 2a24ab161f..6181eaf5e8 100644
--- a/src/components/SettingsPage.tsx
+++ b/src/components/SettingsPage.tsx
@@ -131,6 +131,11 @@ import {
} from "../stores/policyRules";
import { usePolicyModeOptions, usePolicySnapshot } from "../hooks/usePolicy";
import { usePolicyStore } from "../stores/policyStore";
+import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock";
+import {
+ canSelectManagedLocalMode,
+ constrainManagedLocalModeOptions,
+} from "./onboarding/managedLocalModels";
import { canManageSystemAudioInApp } from "../utils/systemAudioAccess";
import WorkspaceSection from "./settings/WorkspaceSection";
import WorkspaceBillingOverview from "./settings/WorkspaceBillingOverview";
@@ -294,6 +299,7 @@ function TranscriptionSection({
toast,
}: TranscriptionSectionProps) {
const { t } = useTranslation();
+ const managedLocalLock = useManagedLocalModelLock("transcription");
const {
modes: transcriptionModes,
effectiveMode: effectiveTranscriptionMode,
@@ -331,7 +337,12 @@ function TranscriptionSection({
transcriptionMode,
{ byokProviders: TRANSCRIPTION_POLICY_PROVIDER_IDS }
);
+ const selectableTranscriptionModes = constrainManagedLocalModeOptions(
+ transcriptionModes,
+ managedLocalLock.managed
+ );
const handleTranscriptionModeSelect = (mode: InferenceMode) => {
+ if (!canSelectManagedLocalMode(managedLocalLock.managed, mode)) return;
if (!isModeAllowed(mode)) return;
if (mode === "openwhispr" && !isSignedIn) {
startOnboarding();
@@ -421,7 +432,7 @@ function TranscriptionSection({
return (
@@ -916,6 +927,9 @@ export default function SettingsPage({
const setTranslationKey = useSettingsStore((s) => s.setTranslationKey);
const settingsPolicyState = usePolicySnapshot();
+ const managedTranscriptionLock = useManagedLocalModelLock("transcription");
+ const managedReasoningLock = useManagedLocalModelLock("reasoning");
+ const hasManagedLocalModels = managedTranscriptionLock.managed || managedReasoningLock.managed;
const agentAllowedByPolicy = isAgentAllowed(settingsPolicyState);
const historyLockedByPolicy = lockedLocalHistoryValue(settingsPolicyState) !== null;
const effectiveDataRetentionEnabled = effectiveLocalHistoryEnabled(
@@ -1378,6 +1392,13 @@ export default function SettingsPage({
const handleRemoveModels = useCallback(() => {
if (isRemovingModels) return;
+ if (hasManagedLocalModels) {
+ showAlertDialog({
+ title: t("managedLocalModels.settings.title"),
+ description: t("managedLocalModels.settings.removalBlocked"),
+ });
+ return;
+ }
showConfirmDialog({
title: t("settingsPage.developer.removeModels.title"),
@@ -1422,7 +1443,14 @@ export default function SettingsPage({
}
},
});
- }, [isRemovingModels, cachePathHint, showConfirmDialog, showAlertDialog, t]);
+ }, [
+ cachePathHint,
+ hasManagedLocalModels,
+ isRemovingModels,
+ showAlertDialog,
+ showConfirmDialog,
+ t,
+ ]);
const { isSignedIn, isLoaded, user, refetch } = useAuth();
// Signed out there is nothing to load and the plan grid is purely
@@ -4121,7 +4149,13 @@ EOF`,
);
}
+
+export default function TranscriptionModelPicker(props: TranscriptionModelPickerProps) {
+ const lock = useManagedLocalModelLock("transcription");
+ if (lock.managed) return ;
+ return ;
+}
diff --git a/src/components/UpgradePrompt.tsx b/src/components/UpgradePrompt.tsx
index a41ca88417..826abdc43e 100644
--- a/src/components/UpgradePrompt.tsx
+++ b/src/components/UpgradePrompt.tsx
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next";
import { useUsage } from "../hooks/useUsage";
import { useBillingPortal } from "../hooks/useBillingPortal";
import { useSettingsStore } from "../stores/settingsStore";
+import { useManagedLocalModelLock } from "../hooks/useManagedLocalModelLock";
+import {
+ applyManagedLocalModeChange,
+ canSelectManagedLocalMode,
+} from "./onboarding/managedLocalModels";
interface UpgradePromptProps {
open: boolean;
@@ -22,6 +27,7 @@ export default function UpgradePrompt({
const usage = useUsage();
const { openBillingPortal } = useBillingPortal(usage);
const isPastDue = usage?.isPastDue ?? false;
+ const managedLocalLock = useManagedLocalModelLock("transcription");
return (