Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "lucide-react";
import UpgradePrompt from "./UpgradePrompt";
import PostMigrationOnboarding from "./PostMigrationOnboarding";
import { RequiredModelsBanner } from "./RequiredModelsBanner";
import { ConfirmDialog, AlertDialog } from "./ui/dialog";
import { useDialogs } from "../hooks/useDialogs";
import { useHotkey } from "../hooks/useHotkey";
Expand Down Expand Up @@ -1099,6 +1100,7 @@ export default function ControlPanel({ initialSettingsSection }: ControlPanelPro
</div>
</div>
)}
<RequiredModelsBanner />
{usage?.isPastDue && activeView === "home" && (
<div className="max-w-3xl mx-auto w-full mb-3">
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/50 p-3">
Expand Down
75 changes: 72 additions & 3 deletions src/components/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle } from "lucide-react";
import { CompactAuthenticationFlow } from "./CompactAuthenticationFlow";
Expand All @@ -13,6 +13,7 @@ import DemoStep from "./onboarding/DemoStep";
import CalendarConnectionsStep from "./onboarding/CalendarConnectionsStep";
import SetupChoiceStep from "./onboarding/SetupChoiceStep";
import { ByokProviderStep, LocalModelSetupStep } from "./onboarding/ProviderSetupStep";
import { RequiredModelDownloadStep } from "./onboarding/RequiredModelDownloadStep";
import { AlertDialog } from "./ui/dialog";
import { useAuth } from "../hooks/useAuth";
import { usePermissions } from "../hooks/usePermissions";
Expand All @@ -23,6 +24,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage";
import { useHotkeyRegistration } from "../hooks/useHotkeyRegistration";
import { useHotkeyModeInfo } from "../hooks/useHotkeyModeInfo";
import { useWorkspace } from "../hooks/useWorkspace";
import { useRequiredLocalModels } from "../hooks/useRequiredLocalModels";
import { usePolicyStore } from "../stores/policyStore";
import { isAgentAllowed } from "../stores/policyRules";
import { useSettingsStore } from "../stores/settingsStore";
Expand Down Expand Up @@ -167,19 +169,55 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
(!workspacesLoaded ||
(!activeWorkspace && skipSetupChoiceForEnterprise && Boolean(enterpriseWorkspace)));

const requiredModels = useRequiredLocalModels();
// Latched for the session once the step is entered (or resumed at), so a
// mid-download policy refresh or the disk check settling can't rebuild the
// route out from under the user. Seeded from the persisted session because
// relaunching mid-download must resume on the step, not bounce off it while
// the disk check is still pending.
const requiredModelsLatchRef = useRef(session.currentStepId === "required-models");
const requiredModelsPending = requiredModelsLatchRef.current || requiredModels.missing.length > 0;

const route = useMemo(
() =>
getOnboardingRoute({
authPath: session.authPath,
setupMode: session.setupMode,
agentAllowed,
requiredModelsPending,
skipSetupChoice: skipSetupChoiceForEnterprise,
}),
[agentAllowed, session.authPath, session.setupMode, skipSetupChoiceForEnterprise]
[
agentAllowed,
requiredModelsPending,
session.authPath,
session.setupMode,
skipSetupChoiceForEnterprise,
]
);
const currentStepId = reconcileStepWithRoute(session.currentStepId, route);
const compact = COMPACT_STEPS.has(currentStepId);

useEffect(() => {
if (currentStepId === "required-models") requiredModelsLatchRef.current = true;
}, [currentStepId]);

// The auth step lands on "permissions" before the policy and disk checks
// settle (AppRouter's policy gate remounts this component mid-transition),
// so a persisted session can sit one step past the gate when the pending
// flag arrives. Pull the user back — only from permissions, the immediate
// post-auth screen, and only before the step was entered this session.
useEffect(() => {
if (
requiredModelsPending &&
currentStepId === "permissions" &&
!requiredModelsLatchRef.current &&
route.includes("required-models")
) {
goTo("required-models");
}
}, [currentStepId, goTo, requiredModelsPending, route]);

useEffect(() => {
if (session.currentStepId !== currentStepId) {
setSession((current) => ({ ...current, currentStepId }));
Expand Down Expand Up @@ -418,6 +456,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
authPath: session.authPath,
setupMode: mode,
agentAllowed,
requiredModelsPending,
});
const next = getNextOnboardingStep("setup-choice", nextRoute);
if (next) goTo(next);
Expand All @@ -426,6 +465,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
agentAllowed,
finalizeOnboarding,
goTo,
requiredModelsPending,
session.authPath,
setSelfHostedRequested,
setSetupMode,
Expand Down Expand Up @@ -532,6 +572,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {

const canContinue = (() => {
switch (currentStepId) {
case "required-models":
return !requiredModels.loading && requiredModels.missing.length === 0;
case "permissions":
return areRequiredPermissionsMet(permissions.micPermissionGranted);
case "languages":
Expand Down Expand Up @@ -581,6 +623,27 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
</div>
);

case "required-models":
return (
<div className="h-full w-full pt-2">
<OnboardingStepHeader
title={t("onboarding.requiredModels.title")}
wideTitle
description={t("onboarding.requiredModels.description", {
organization:
activeWorkspace?.name ?? t("onboarding.requiredModels.genericOrganization"),
})}
/>
<RequiredModelDownloadStep
required={requiredModels.required}
missing={requiredModels.missing}
loading={requiredModels.loading}
refresh={requiredModels.refresh}
onProceed={() => void continueFromCurrentStep()}
/>
</div>
);

case "permissions":
return (
<CompactPermissionsStep
Expand Down Expand Up @@ -931,7 +994,13 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
stepKey={currentStepId}
// 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}
// The required-models step is the exception: it is an org-mandated
// blocker, so backing out of it (to auth) is suppressed.
onBack={
hasShellNavigation && session.history.length > 0 && currentStepId !== "required-models"
? goBack
: undefined
}
onContinue={showsContinue ? () => void continueFromCurrentStep() : undefined}
// The demos are practice, not configuration — a mic problem or an
// unreachable transcription backend must never dead-end setup, so they
Expand Down
96 changes: 96 additions & 0 deletions src/components/RequiredModelsBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useCallback, useMemo, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "./ui/button";
import { DownloadProgressBar } from "./ui/DownloadProgressBar";
import { useModelDownload } from "../hooks/useModelDownload";
import { useRequiredLocalModels } from "../hooks/useRequiredLocalModels";
import { getParakeetModels, getWhisperModels } from "../models/ModelRegistry";

/**
* Non-dismissable amber banner for managed users missing org-required local
* models after onboarding (the admin added a requirement later, or the user
* deleted a required model). Mirrors the update-required banner; disk truth
* makes it disappear as soon as every required model is installed and return
* if one goes missing again. Deliberately a nag surface, not a hard block.
*/
export function RequiredModelsBanner() {
const { t } = useTranslation();
const { missing, loading, refresh } = useRequiredLocalModels();
const whisperDownload = useModelDownload({ modelType: "whisper", onDownloadComplete: refresh });
const parakeetDownload = useModelDownload({ modelType: "parakeet", onDownloadComplete: refresh });
const [downloadingAll, setDownloadingAll] = useState(false);

const parakeetCatalog = getParakeetModels();
const whisperCatalog = getWhisperModels();
const missingNames = useMemo(
() =>
missing
.map((modelId) => (parakeetCatalog[modelId] ?? whisperCatalog[modelId])?.name ?? modelId)
.join(", "),
[missing, parakeetCatalog, whisperCatalog]
);

const downloadMissing = useCallback(async () => {
setDownloadingAll(true);
try {
// Sequential on purpose: the per-family managers reject concurrent
// downloads, and downloadModel resolves only once its download settles.
// Failures surface through the hook's alert dialog and leave the model
// missing, so the banner (and this button) stick around for a retry.
for (const modelId of missing) {
const download = modelId in parakeetCatalog ? parakeetDownload : whisperDownload;
await download.downloadModel(modelId);
}
} finally {
setDownloadingAll(false);
}
}, [missing, parakeetCatalog, parakeetDownload, whisperDownload]);

if (loading || missing.length === 0) return null;

const activeDownload = parakeetDownload.isDownloading ? parakeetDownload : whisperDownload;
const activeModelName = activeDownload.downloadingModel
? ((
parakeetCatalog[activeDownload.downloadingModel] ??
whisperCatalog[activeDownload.downloadingModel]
)?.name ?? activeDownload.downloadingModel)
: null;

return (
<div className="max-w-3xl mx-auto w-full mb-3">
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/50 p-3">
<div className="flex items-start gap-3">
<div className="shrink-0 w-8 h-8 rounded-md bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
<AlertTriangle size={16} className="text-amber-600 dark:text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-amber-900 dark:text-amber-200 mb-0.5">
{t("controlPanel.requiredModelsByOrg.title")}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300/80 mb-2">
{t("controlPanel.requiredModelsByOrg.description", { models: missingNames })}
</p>
{activeDownload.isDownloading && activeModelName ? (
<DownloadProgressBar
modelName={activeModelName}
progress={activeDownload.downloadProgress}
isInstalling={activeDownload.isInstalling}
/>
) : (
<Button
variant="default"
size="sm"
className="h-7 text-xs"
disabled={downloadingAll}
onClick={() => void downloadMissing()}
>
{t("controlPanel.requiredModelsByOrg.download")}
</Button>
)}
</div>
</div>
</div>
</div>
);
}
12 changes: 12 additions & 0 deletions src/components/onboarding/BackgroundModelDownloadTray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
WhisperDownloadProgressData,
} from "../../types/electron";
import { mergeHydratedDownloads } from "./localDownloadState";
import { ONBOARDING_SESSION_KEY, isRequiredModelsOnboardingStepActive } from "./flow";

type DownloadKind = "whisper" | "parakeet" | "llm";

Expand Down Expand Up @@ -226,6 +227,17 @@ export default function BackgroundModelDownloadTray() {
percentage: number | undefined;
error?: string;
}) => {
// The required-models onboarding step owns its downloads: it renders its
// own per-row progress, and cancelling from here cannot stick because the
// step auto-restarts org-mandated downloads. Suppress row creation while
// that step is active; completions still pass so any pre-existing row
// (a resumed local-setup download) can clear and activate normally.
if (
event.type !== "complete" &&
isRequiredModelsOnboardingStepActive(localStorage.getItem(ONBOARDING_SESSION_KEY))
) {
return;
}
const key = downloadKey(event.kind, event.id);
const cancelledAt = cancelledKeys.current.get(key);
if (cancelledAt !== undefined && event.type !== "complete") {
Expand Down
2 changes: 1 addition & 1 deletion src/components/onboarding/ProviderSetupStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function StepSecondaryAction({
}

/** The card each setup mode's step renders into. Top margin is per call site. */
const SETUP_CARD_CLASS =
export const SETUP_CARD_CLASS =
"mx-auto w-full max-w-[22rem] rounded-[1.125rem] border border-[var(--onboarding-control-border)] bg-[var(--onboarding-surface)] px-3 py-4 text-[var(--onboarding-text-primary)]";

/** The field trigger. Call sites that can be disabled add the disabled: variants. */
Expand Down
Loading
Loading