diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index d0d06295b..f44bd248c 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -413,8 +413,8 @@ const BillingPanel = () => { )}

{currentTier === 'FREE' - ? 'Free accounts do not include recurring monthly credits. New accounts may receive a one-time signup credit, and any additional usage is covered by pay-as-you-go credits.' - : 'Your subscription includes premium usage each cycle. Pay-as-you-go credits cover overage after the included budget is consumed.'} + ? 'Free accounts use pay-as-you-go credits. New accounts may receive a one-time signup credit.' + : 'Your subscription includes premium usage each cycle. Top-ups cover any overage.'}

{currentPlan && (
@@ -433,12 +433,10 @@ const BillingPanel = () => { 10-hour cap: {formatUsdAmount(currentPlan.fiveHourCapUsd)} )} - {currentPlanMeta && ( - <> - - Premium-usage discount: {currentPlanMeta.discountPercent}% - - + {currentPlanMeta && currentPlanMeta.discountPercent > 0 && ( + + Premium-usage discount: {currentPlanMeta.discountPercent}% + )}
)} @@ -457,7 +455,7 @@ const BillingPanel = () => {
- Or subscribe for recurring included usage + discounts + Or subscribe for included usage + discounts
@@ -506,52 +504,6 @@ const BillingPanel = () => { onDeleteCard={handleDeleteCard} onAddCard={handleAddCard} /> - - {/* ── Why upgrade? ───────────────────────────────────── */} -
-
-

Why upgrade?

-
    -
  • - - - - - Higher tiers increase your premium-usage discount and included usage every cycle - -
  • - {currentTier === 'FREE' && ( -
  • - - - - - Annual billing lowers the effective monthly price, and top-ups let you keep - going when usage spikes - -
  • - )} -
-
-
diff --git a/app/src/components/settings/panels/billing/AutoRechargeSection.tsx b/app/src/components/settings/panels/billing/AutoRechargeSection.tsx index e12dd23cb..b643d12fc 100644 --- a/app/src/components/settings/panels/billing/AutoRechargeSection.tsx +++ b/app/src/components/settings/panels/billing/AutoRechargeSection.tsx @@ -71,330 +71,328 @@ const AutoRechargeSection = ({ onDeleteCard, onAddCard, }: AutoRechargeSectionProps) => ( -
-
- {/* Header row */} -
-
-

Auto-Recharge Credits

-

- Automatically top up when your balance runs low -

-
- {arLoading ? ( -
- ) : ( - - )} +
+ {/* Header row */} +
+
+

Auto-Recharge Credits

+

+ Automatically top up when your balance runs low +

+ {arLoading ? ( +
+ ) : ( + + )} +
- {/* Error banner */} - {arError && ( -
- + {/* Error banner */} + {arError && ( +
+ + + +

{arError}

+ -
- )} + +
+ )} - {/* Settings — only shown when enabled */} - {!arLoading && arSettings?.enabled && ( -
- {/* Status row */} -
- {arSettings.inFlight && ( - - - - - - Recharge in progress - - )} - {arSettings.spentThisWeekUsd > 0 && ( - - ${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd} used this - week - - )} - {arSettings.lastRechargeAt && ( - - Last recharged{' '} - {new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - })} - - )} -
- - {/* Last error from recharge attempt */} - {arSettings.lastError && ( -
- + {/* Settings — only shown when enabled */} + {!arLoading && arSettings?.enabled && ( +
+ {/* Status row */} +
+ {arSettings.inFlight && ( + + + -

- Last recharge failed: {arSettings.lastError} -

-
+ Recharge in progress + )} - - {/* Trigger threshold */} -
-

Recharge when balance drops below

-
- {THRESHOLD_OPTIONS.map(v => ( - - ))} -
-
- - {/* Recharge amount */} -
-

Add this amount

-
- {RECHARGE_OPTIONS.map(v => ( - - ))} -
-
- - {/* Weekly limit */} -
-

Weekly spending limit

-
- {WEEKLY_LIMIT_OPTIONS.map(v => ( - - ))} -
-
- - {/* Validation hint */} - {arAmount <= arThreshold && ( -

- Recharge amount should be greater than the trigger threshold. -

+ {arSettings.spentThisWeekUsd > 0 && ( + + ${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd} used this + week + )} - - {/* Save button */} - {arDirty && ( - + {arSettings.lastRechargeAt && ( + + Last recharged{' '} + {new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })} + )}
- )} - {/* Payment methods */} -
-
-

Payment Methods

- -
- - {cardsLoading ? ( -
- {[0, 1].map(i => ( -
- ))} -
- ) : cards.length === 0 ? ( -
+ {/* Last error from recharge attempt */} + {arSettings.lastError && ( +
-

- No saved cards. Add one to enable auto-recharge. +

+ Last recharge failed: {arSettings.lastError}

- ) : ( -
- {cards.map(card => { - const isDeleting = deletingCardId === card.id; - const isSettingDefault = settingDefaultId === card.id; - const isConfirming = confirmDeleteId === card.id; + )} - return ( -
- {/* Card icon */} - - - - - {/* Card info */} -
-
- - {cardBrandLabel(card.brand)} ••••{card.last4} - - {card.isDefault && ( - - Default - - )} -
-

- Expires {String(card.expMonth).padStart(2, '0')}/ - {String(card.expYear).slice(-2)} -

-
- - {/* Actions */} -
- {!card.isDefault && ( - - )} - - {isConfirming ? ( -
- - -
- ) : ( - - )} -
-
- ); - })} + {/* Trigger threshold */} +
+

Recharge when balance drops below

+
+ {THRESHOLD_OPTIONS.map(v => ( + + ))}
+
+ + {/* Recharge amount */} +
+

Add this amount

+
+ {RECHARGE_OPTIONS.map(v => ( + + ))} +
+
+ + {/* Weekly limit */} +
+

Weekly spending limit

+
+ {WEEKLY_LIMIT_OPTIONS.map(v => ( + + ))} +
+
+ + {/* Validation hint */} + {arAmount <= arThreshold && ( +

+ Recharge amount should be greater than the trigger threshold. +

+ )} + + {/* Save button */} + {arDirty && ( + )}
+ )} + + {/* Payment methods */} +
+
+

Payment Methods

+ +
+ + {cardsLoading ? ( +
+ {[0, 1].map(i => ( +
+ ))} +
+ ) : cards.length === 0 ? ( +
+ + + +

+ No saved cards. Add one to enable auto-recharge. +

+
+ ) : ( +
+ {cards.map(card => { + const isDeleting = deletingCardId === card.id; + const isSettingDefault = settingDefaultId === card.id; + const isConfirming = confirmDeleteId === card.id; + + return ( +
+ {/* Card icon */} + + + + + {/* Card info */} +
+
+ + {cardBrandLabel(card.brand)} ••••{card.last4} + + {card.isDefault && ( + + Default + + )} +
+

+ Expires {String(card.expMonth).padStart(2, '0')}/ + {String(card.expYear).slice(-2)} +

+
+ + {/* Actions */} +
+ {!card.isDefault && ( + + )} + + {isConfirming ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); + })} +
+ )}
); diff --git a/app/src/components/settings/panels/billing/InferenceBudget.tsx b/app/src/components/settings/panels/billing/InferenceBudget.tsx index da67d2fb6..4e70431fa 100644 --- a/app/src/components/settings/panels/billing/InferenceBudget.tsx +++ b/app/src/components/settings/panels/billing/InferenceBudget.tsx @@ -21,7 +21,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps) {teamUsage ? ( teamUsage.cycleBudgetUsd > 0 ? ( <> -
+
- - 5-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ - {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} - - + {((teamUsage.cycleLimit5hr ?? 0) > 0 || (teamUsage.fiveHourCapUsd ?? 0) > 0) && ( + + 10-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ + {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} + + )} + Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
@@ -63,7 +65,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
) ) : isLoadingCredits ? ( -
+
) : (

Unable to load usage data

)} diff --git a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx index b7afab80e..f45c66300 100644 --- a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx +++ b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx @@ -100,8 +100,7 @@ const PayAsYouGoCard = ({ )}

- No subscription needed. Free users spend from any signup or promo credit first, then from - top-ups. Paid plans still consume included budget before pay-as-you-go credits. + Credits are used after any included subscription budget is consumed.

{/* Top-up buttons */} diff --git a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx index c913c242e..f418caa89 100644 --- a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx +++ b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx @@ -1,11 +1,5 @@ import type { PlanTier } from '../../../../types/api'; -import { - annualSavings, - isUpgrade as checkIsUpgrade, - displayPrice, - formatUsdAmount, - PLANS, -} from '../billingHelpers'; +import { annualSavings, isUpgrade as checkIsUpgrade, displayPrice, PLANS } from '../billingHelpers'; interface SubscriptionPlansProps { currentTier: PlanTier; @@ -32,7 +26,7 @@ const SubscriptionPlans = ({ }: SubscriptionPlansProps) => ( <> {/* Interval toggle */} -
+
{/* Plan tier cards */} -
+
{PLANS.map(plan => { const isCurrent = plan.tier === currentTier; const isUpgrade = checkIsUpgrade(plan.tier, currentTier); @@ -67,76 +61,105 @@ const SubscriptionPlans = ({ return (
-
-
-
-

{plan.name}

- {plan.features.map(f => ( - - - {f.text} - - ))} - {isCurrent && ( - - Current - - )} - {savings && ( - - Save {savings}% - - )} -
-
- + {/* Popular badge */} + {plan.recommended && ( + + Popular + + )} + + {/* Header: name + tagline on left, price on right */} +
+
+

{plan.name}

+ {plan.tagline &&

{plan.tagline}

} +
+
+
+ {displayPrice(plan, billingInterval)} {plan.tier !== 'FREE' && /mo} - {plan.tier !== 'FREE' && billingInterval === 'annual' && ( - - (billed ${plan.annualPrice}/yr) - - )}
-
- {plan.monthlyBudgetUsd > 0 && ( - - Included monthly value: {formatUsdAmount(plan.monthlyBudgetUsd)} - - )} - {plan.weeklyBudgetUsd > 0 && ( - - 7-day cycle: {formatUsdAmount(plan.weeklyBudgetUsd)} - - )} - {plan.fiveHourCapUsd > 0 && ( - - 10-hour cap: {formatUsdAmount(plan.fiveHourCapUsd)} - - )} - - Discount: {plan.discountPercent}% + {plan.tier !== 'FREE' && billingInterval === 'annual' && ( +

billed ${plan.annualPrice}/yr

+ )} + {savings && ( + + Save {savings}% -
+ )}
+
- {/* Action button */} - {isUpgrade && ( + {/* Divider */} +
+ + {/* Feature list */} +
    + {plan.features.map(f => ( +
  • + {f.included ? ( + + + + ) : ( + + + + )} + + {f.text} + +
  • + ))} +
+ + {/* CTA */} +
+ {isCurrent ? ( +
+ Current Plan +
+ ) : isUpgrade ? ( - )} + ) : null}
); @@ -145,7 +168,7 @@ const SubscriptionPlans = ({ {/* Payment confirmed banner */} {paymentConfirmed && ( -
+
+
+

Pay with Crypto

diff --git a/app/src/components/settings/panels/billingHelpers.ts b/app/src/components/settings/panels/billingHelpers.ts index 7262d8db5..e06d4a929 100644 --- a/app/src/components/settings/panels/billingHelpers.ts +++ b/app/src/components/settings/panels/billingHelpers.ts @@ -16,6 +16,8 @@ export interface PlanMeta { fiveHourCapUsd: number; discountPercent: number; features: PlanFeature[]; + recommended?: boolean; + tagline?: string; } export const PLANS: PlanMeta[] = [ @@ -28,11 +30,12 @@ export const PLANS: PlanMeta[] = [ weeklyBudgetUsd: 0, fiveHourCapUsd: 0, discountPercent: 0, + tagline: 'Get started at no cost', features: [ - { text: 'Base access to integrations and inference', included: true }, + { text: 'Access to all integrations and inference', included: true }, { text: 'One-time signup credits when available', included: true }, - { text: 'Pay-as-you-go top-ups when credits run out', included: true }, - { text: 'No subscription discount on premium usage', included: true }, + { text: 'Pay-as-you-go top-ups', included: true }, + { text: 'No subscription discount', included: false }, ], }, { @@ -44,13 +47,12 @@ export const PLANS: PlanMeta[] = [ weeklyBudgetUsd: 10, fiveHourCapUsd: 3, discountPercent: 20, + recommended: true, + tagline: 'Best value for most users', features: [ - { text: 'Higher included premium usage every billing cycle', included: true }, - { - text: '20% premium-usage discount across integrations, bandwidth, and inference', - included: true, - }, - { text: 'Pay-as-you-go top-ups for overflow usage', included: true }, + { text: '$20/mo in premium usage included', included: true }, + { text: '20% discount on all premium usage', included: true }, + { text: 'Pay-as-you-go top-ups for overflow', included: true }, ], }, { @@ -62,10 +64,11 @@ export const PLANS: PlanMeta[] = [ weeklyBudgetUsd: 100, fiveHourCapUsd: 30, discountPercent: 40, + tagline: 'For power users and teams', features: [ - { text: 'Largest included premium usage allocation', included: true }, - { text: '40% premium-usage discount across integrations and inference', included: true }, - { text: 'Best fit for heavy bandwidth and agent workloads', included: true }, + { text: '$200/mo in premium usage included', included: true }, + { text: '40% discount on all premium usage', included: true }, + { text: 'Best fit for heavy bandwidth and agents', included: true }, ], }, ]; diff --git a/app/src/components/skills/AutocompleteSetupModal.tsx b/app/src/components/skills/AutocompleteSetupModal.tsx new file mode 100644 index 000000000..21cd9f0b6 --- /dev/null +++ b/app/src/components/skills/AutocompleteSetupModal.tsx @@ -0,0 +1,189 @@ +/** + * Text Auto-Complete setup/enable modal. + * + * Simple enable flow: shows current state, lets user enable with one click, + * and shows a success confirmation — matching the UX of the Screen + * Intelligence setup modal. + */ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; + +import { useCoreState } from '../../providers/CoreStateProvider'; +import { + openhumanAutocompleteSetStyle, + openhumanAutocompleteStart, +} from '../../utils/tauriCommands/autocomplete'; + +type Step = 'enable' | 'success'; + +interface Props { + onClose: () => void; +} + +export default function AutocompleteSetupModal({ onClose }: Props) { + const navigate = useNavigate(); + const { snapshot, refresh } = useCoreState(); + const status = snapshot.runtime.autocomplete; + + const [step, setStep] = useState('enable'); + const [isEnabling, setIsEnabling] = useState(false); + const [enableError, setEnableError] = useState(null); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleEnable = async () => { + setIsEnabling(true); + setEnableError(null); + try { + // Enable in config + await openhumanAutocompleteSetStyle({ enabled: true }); + // Start the service + await openhumanAutocompleteStart(); + await refresh(); + setStep('success'); + } catch (error) { + setEnableError(error instanceof Error ? error.message : 'Failed to enable autocomplete'); + } finally { + setIsEnabling(false); + } + }; + + const handleGoToSettings = () => { + onClose(); + navigate('/settings/autocomplete'); + }; + + return createPortal( +

{ + if (e.target === e.currentTarget) onClose(); + }}> +
+ {/* Header */} +
+
+
+ + + +
+
+

Text Auto-Complete

+

+ {step === 'enable' && 'Enable inline completions'} + {step === 'success' && 'Ready to go'} +

+
+
+ +
+ + {/* Body */} +
+ {/* ─── Enable step ─── */} + {step === 'enable' && ( +
+

+ Text Auto-Complete suggests inline completions as you type across any app. Suggestions appear as an overlay you can accept with Tab. +

+ + {!status?.platform_supported && status !== null && ( +
+ Auto-complete is not supported on this platform. +
+ )} + +
+
+ Style preset + Balanced (configurable later) +
+
+ Accept key + Tab +
+
+ Debounce + {status?.debounce_ms ?? 120}ms +
+
+ + {enableError && ( +
+ {enableError} +
+ )} + + +
+ )} + + {/* ─── Success step ─── */} + {step === 'success' && ( +
+
+ + + +
+ +
+

Auto-Complete is Active

+

+ Start typing in any app and suggestions will appear as an inline overlay. Press Tab to accept. +

+
+ +
+ + +
+
+ )} +
+
+
, + document.body + ); +} diff --git a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx new file mode 100644 index 000000000..9f845d72f --- /dev/null +++ b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx @@ -0,0 +1,348 @@ +/** + * Screen Intelligence setup/enable modal. + * + * Guides the user through permission grants, enables the feature, + * and shows a success confirmation — matching the UX of third-party + * skill setup flows (Gmail, etc.). + */ +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; + +import { useScreenIntelligenceState } from '../../features/screen-intelligence/useScreenIntelligenceState'; +import { openhumanUpdateScreenIntelligenceSettings } from '../../utils/tauriCommands'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Step = 'permissions' | 'enable' | 'success'; + +interface Props { + onClose: () => void; + /** Skip straight to manage mode when permissions are already granted. */ + initialStep?: Step; +} + +// ─── Permission badge (reusable) ────────────────────────────────────────────── + +const PermissionRow = ({ + label, + value, + onRequest, + isRequesting, +}: { + label: string; + value: string; + onRequest: () => void; + isRequesting: boolean; +}) => { + const granted = value === 'granted'; + const badgeColor = granted + ? 'bg-sage-50 text-sage-700 border-sage-200' + : value === 'denied' + ? 'bg-coral-50 text-coral-700 border-coral-200' + : 'bg-stone-100 text-stone-600 border-stone-200'; + + return ( +
+
+ {granted ? ( + + + + ) : ( + + + + )} + {label} +
+ {granted ? ( + + Granted + + ) : ( + + )} +
+ ); +}; + +// ─── Modal ──────────────────────────────────────────────────────────────────── + +export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: Props) { + const navigate = useNavigate(); + const { + status, + isRequestingPermissions, + isRestartingCore, + lastRestartSummary, + lastError, + requestPermission, + refreshPermissionsWithRestart, + refreshStatus, + } = useScreenIntelligenceState({ loadVision: false }); + + const [isEnabling, setIsEnabling] = useState(false); + const [enableError, setEnableError] = useState(null); + + const allGranted = useMemo(() => { + if (!status) return false; + return ( + status.permissions.screen_recording === 'granted' && + status.permissions.accessibility === 'granted' && + status.permissions.input_monitoring === 'granted' + ); + }, [status]); + + const anyDenied = useMemo(() => { + if (!status) return false; + return ( + status.permissions.screen_recording === 'denied' || + status.permissions.accessibility === 'denied' || + status.permissions.input_monitoring === 'denied' + ); + }, [status]); + + // Derive current step + const [step, setStep] = useState(initialStep ?? 'permissions'); + + // Auto-advance: when permissions are all granted, move past the permissions step + useEffect(() => { + if (step === 'permissions' && allGranted) { + setStep('enable'); + } + }, [step, allGranted]); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleEnable = async () => { + setIsEnabling(true); + setEnableError(null); + try { + await openhumanUpdateScreenIntelligenceSettings({ enabled: true }); + await refreshStatus(); + setStep('success'); + } catch (error) { + setEnableError(error instanceof Error ? error.message : 'Failed to enable Screen Intelligence'); + } finally { + setIsEnabling(false); + } + }; + + const handleGoToSettings = () => { + onClose(); + navigate('/settings/screen-intelligence'); + }; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }}> +
+ {/* Header */} +
+
+
+ + + +
+
+

Screen Intelligence

+

+ {step === 'permissions' && 'Grant permissions'} + {step === 'enable' && 'Enable the skill'} + {step === 'success' && 'Ready to go'} +

+
+
+ +
+ + {/* Body */} +
+ {/* ─── Step 1: Permissions ─── */} + {step === 'permissions' && ( +
+

+ Screen Intelligence needs macOS permissions to capture your screen and provide context to your AI assistant. +

+ +
+ void requestPermission('screen_recording')} + isRequesting={isRequestingPermissions} + /> + void requestPermission('accessibility')} + isRequesting={isRequestingPermissions} + /> + void requestPermission('input_monitoring')} + isRequesting={isRequestingPermissions} + /> +
+ + {anyDenied && ( +
+

After granting permissions in System Settings, click below to restart and pick up the changes.

+ {status?.permission_check_process_path && ( +

+ macOS applies privacy to:{' '} + + {status.permission_check_process_path} + +

+ )} +
+ )} + + {lastRestartSummary && ( +
+ {lastRestartSummary} +
+ )} + + {lastError && ( +
+ {lastError} +
+ )} + +
+ {anyDenied ? ( + + ) : ( + + )} +
+
+ )} + + {/* ─── Step 2: Enable ─── */} + {step === 'enable' && ( +
+
+ + + + All permissions granted +
+ +

+ Enable Screen Intelligence to continuously capture what's on your screen and feed useful context into your AI assistant's memory. +

+ +
+
+ Capture mode + All windows (configurable later) +
+
+ Vision model + Enabled +
+
+ Panic hotkey + {status?.session.panic_hotkey ?? 'Cmd+Shift+.'} +
+
+ + {enableError && ( +
+ {enableError} +
+ )} + + +
+ )} + + {/* ─── Step 3: Success ─── */} + {step === 'success' && ( +
+
+ + + +
+ +
+

Screen Intelligence is Enabled

+

+ Screen Intelligence is now enabled. Start a session from the settings panel to begin capturing screen context for your AI assistant. +

+
+ +
+ + +
+
+ )} +
+
+
, + document.body + ); +} diff --git a/app/src/components/skills/VoiceSetupModal.tsx b/app/src/components/skills/VoiceSetupModal.tsx new file mode 100644 index 000000000..11afa10f0 --- /dev/null +++ b/app/src/components/skills/VoiceSetupModal.tsx @@ -0,0 +1,224 @@ +/** + * Voice Intelligence setup/enable modal. + * + * Two-step flow: if STT model isn't downloaded, directs to Local Model + * settings. Otherwise, starts the voice server and shows success. + */ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; + +import type { VoiceSkillStatus } from '../../features/voice/useVoiceSkillStatus'; +import { + openhumanVoiceServerStart, + openhumanUpdateVoiceServerSettings, +} from '../../utils/tauriCommands/voice'; + +type Step = 'setup' | 'enable' | 'success'; + +interface Props { + onClose: () => void; + skillStatus: VoiceSkillStatus; +} + +export default function VoiceSetupModal({ onClose, skillStatus }: Props) { + const navigate = useNavigate(); + const { sttModelMissing, serverStatus } = skillStatus; + + const [step, setStep] = useState(sttModelMissing ? 'setup' : 'enable'); + const [isEnabling, setIsEnabling] = useState(false); + const [enableError, setEnableError] = useState(null); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleEnable = async () => { + setIsEnabling(true); + setEnableError(null); + try { + // Enable auto-start in settings + await openhumanUpdateVoiceServerSettings({ auto_start: true }); + // Start the voice server + await openhumanVoiceServerStart(); + setStep('success'); + } catch (error) { + setEnableError(error instanceof Error ? error.message : 'Failed to start voice server'); + } finally { + setIsEnabling(false); + } + }; + + const handleGoToLocalModel = () => { + onClose(); + navigate('/settings/local-model'); + }; + + const handleGoToSettings = () => { + onClose(); + navigate('/settings/voice'); + }; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }}> +
+ {/* Header */} +
+
+
+ + + +
+
+

Voice Intelligence

+

+ {step === 'setup' && 'Model download required'} + {step === 'enable' && 'Start voice server'} + {step === 'success' && 'Ready to go'} +

+
+
+ +
+ + {/* Body */} +
+ {/* ─── Setup step: STT model missing ─── */} + {step === 'setup' && ( +
+
+ + + +
+

Speech-to-text model not ready

+

Voice Intelligence requires a local Whisper model for transcription. Download it from the Local Model settings.

+
+
+ +

+ Once the STT model is downloaded, you can return here to enable voice dictation and voice-driven AI chat. +

+ +
+ + +
+
+ )} + + {/* ─── Enable step ─── */} + {step === 'enable' && ( +
+
+ + + + Speech-to-text model ready +
+ +

+ Start the voice server to use dictation and voice-driven chat. Press the hotkey to toggle recording. +

+ +
+
+ Hotkey + {serverStatus?.hotkey ?? 'Fn'} +
+
+ Activation + {serverStatus?.activation_mode === 'push' ? 'Push-to-talk' : 'Tap to toggle'} +
+
+ + {enableError && ( +
+ {enableError} +
+ )} + + +
+ )} + + {/* ─── Success step ─── */} + {step === 'success' && ( +
+
+ + + +
+ +
+

Voice Intelligence is Active

+

+ Press {serverStatus?.hotkey ?? 'Fn'} to start dictating. Your voice will be transcribed and sent to your AI assistant. +

+
+ +
+ + +
+
+ )} +
+
+
, + document.body + ); +} diff --git a/app/src/features/autocomplete/useAutocompleteSkillStatus.ts b/app/src/features/autocomplete/useAutocompleteSkillStatus.ts new file mode 100644 index 000000000..4b054f43e --- /dev/null +++ b/app/src/features/autocomplete/useAutocompleteSkillStatus.ts @@ -0,0 +1,102 @@ +/** + * Derives a skill-card-friendly status for Text Auto-Complete, + * matching the state vocabulary used by third-party skills (Gmail, etc.). + */ +import { useMemo } from 'react'; + +import type { SkillConnectionStatus } from '../../lib/skills/types'; +import { useCoreState } from '../../providers/CoreStateProvider'; + +export interface AutocompleteSkillStatus { + connectionStatus: SkillConnectionStatus; + statusDot: string; + statusLabel: string; + statusColor: string; + ctaLabel: string; + ctaVariant: 'primary' | 'sage' | 'amber'; + /** True when the platform doesn't support autocomplete. */ + platformUnsupported: boolean; +} + +export function useAutocompleteSkillStatus(): AutocompleteSkillStatus { + const { snapshot } = useCoreState(); + const status = snapshot.runtime.autocomplete; + + return useMemo(() => { + // No status yet (core not ready or not in Tauri) + if (!status) { + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Offline', + statusColor: 'text-stone-500', + ctaLabel: 'Enable', + ctaVariant: 'sage' as const, + platformUnsupported: false, + }; + } + + if (!status.platform_supported) { + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Unsupported', + statusColor: 'text-stone-500', + ctaLabel: 'Details', + ctaVariant: 'primary' as const, + platformUnsupported: true, + }; + } + + // Running — fully active (checked before error so a stale last_error + // doesn't mask a successfully running service) + if (status.running) { + return { + connectionStatus: 'connected' as SkillConnectionStatus, + statusDot: 'bg-sage-500', + statusLabel: 'Active', + statusColor: 'text-sage-400', + ctaLabel: 'Manage', + ctaVariant: 'primary' as const, + platformUnsupported: false, + }; + } + + // Error state (only when not running) + if (status.last_error) { + return { + connectionStatus: 'error' as SkillConnectionStatus, + statusDot: 'bg-coral-500', + statusLabel: 'Error', + statusColor: 'text-coral-400', + ctaLabel: 'Retry', + ctaVariant: 'amber' as const, + platformUnsupported: false, + }; + } + + // Enabled in config but not running + if (status.enabled) { + return { + connectionStatus: 'disconnected' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Enabled', + statusColor: 'text-stone-400', + ctaLabel: 'Manage', + ctaVariant: 'primary' as const, + platformUnsupported: false, + }; + } + + // Not enabled + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Disabled', + statusColor: 'text-stone-500', + ctaLabel: 'Enable', + ctaVariant: 'sage' as const, + platformUnsupported: false, + }; + }, [status]); +} diff --git a/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts b/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts new file mode 100644 index 000000000..b6d6121cf --- /dev/null +++ b/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts @@ -0,0 +1,115 @@ +/** + * Derives a skill-card-friendly status for Screen Intelligence, + * matching the state vocabulary used by third-party skills (Gmail, etc.). + */ +import { useMemo } from 'react'; + +import type { SkillConnectionStatus } from '../../lib/skills/types'; +import { useCoreState } from '../../providers/CoreStateProvider'; + +export interface ScreenIntelligenceSkillStatus { + connectionStatus: SkillConnectionStatus; + statusDot: string; + statusLabel: string; + statusColor: string; + ctaLabel: string; + ctaVariant: 'primary' | 'sage' | 'amber'; + /** True when all three macOS permissions are granted. */ + allPermissionsGranted: boolean; + /** True when the platform doesn't support screen intelligence. */ + platformUnsupported: boolean; +} + +export function useScreenIntelligenceSkillStatus(): ScreenIntelligenceSkillStatus { + const { snapshot } = useCoreState(); + const status = snapshot.runtime.screenIntelligence; + + return useMemo(() => { + // No status yet (core not ready or not in Tauri) + if (!status) { + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Offline', + statusColor: 'text-stone-500', + ctaLabel: 'Enable', + ctaVariant: 'sage' as const, + allPermissionsGranted: false, + platformUnsupported: false, + }; + } + + if (!status.platform_supported) { + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Unsupported', + statusColor: 'text-stone-500', + ctaLabel: 'Details', + ctaVariant: 'primary' as const, + allPermissionsGranted: false, + platformUnsupported: true, + }; + } + + const { permissions, session, config } = status; + const allGranted = + permissions.screen_recording === 'granted' && + permissions.accessibility === 'granted' && + permissions.input_monitoring === 'granted'; + + // Permissions missing — needs setup + if (!allGranted) { + return { + connectionStatus: 'setup_required' as SkillConnectionStatus, + statusDot: 'bg-primary-400', + statusLabel: 'Setup', + statusColor: 'text-primary-400', + ctaLabel: 'Setup', + ctaVariant: 'primary' as const, + allPermissionsGranted: false, + platformUnsupported: false, + }; + } + + // Session active — fully connected + if (session.active) { + return { + connectionStatus: 'connected' as SkillConnectionStatus, + statusDot: 'bg-sage-500', + statusLabel: 'Active', + statusColor: 'text-sage-400', + ctaLabel: 'Manage', + ctaVariant: 'primary' as const, + allPermissionsGranted: true, + platformUnsupported: false, + }; + } + + // Permissions granted, enabled in config, but session not active + if (config.enabled) { + return { + connectionStatus: 'disconnected' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Enabled', + statusColor: 'text-stone-400', + ctaLabel: 'Manage', + ctaVariant: 'primary' as const, + allPermissionsGranted: true, + platformUnsupported: false, + }; + } + + // Permissions granted but not enabled + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Disabled', + statusColor: 'text-stone-500', + ctaLabel: 'Enable', + ctaVariant: 'sage' as const, + allPermissionsGranted: true, + platformUnsupported: false, + }; + }, [status]); +} diff --git a/app/src/features/voice/useVoiceSkillStatus.ts b/app/src/features/voice/useVoiceSkillStatus.ts new file mode 100644 index 000000000..36167db21 --- /dev/null +++ b/app/src/features/voice/useVoiceSkillStatus.ts @@ -0,0 +1,156 @@ +/** + * Derives a skill-card-friendly status for Voice Intelligence, + * matching the state vocabulary used by third-party skills (Gmail, etc.). + * + * Voice has a dependency on Local AI models (STT must be downloaded), + * so the status reflects that prerequisite. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { SkillConnectionStatus } from '../../lib/skills/types'; +import { useCoreState } from '../../providers/CoreStateProvider'; +import { isTauri } from '../../utils/tauriCommands/common'; +import { + openhumanVoiceServerStatus, + openhumanVoiceStatus, + type VoiceServerStatus, + type VoiceStatus, +} from '../../utils/tauriCommands/voice'; + +export interface VoiceSkillStatus { + connectionStatus: SkillConnectionStatus; + statusDot: string; + statusLabel: string; + statusColor: string; + ctaLabel: string; + ctaVariant: 'primary' | 'sage' | 'amber'; + /** True when STT model is not yet downloaded. */ + sttModelMissing: boolean; + /** Voice system availability info (null before first fetch). */ + voiceStatus: VoiceStatus | null; + /** Voice server runtime state (null before first fetch). */ + serverStatus: VoiceServerStatus | null; +} + +export function useVoiceSkillStatus(): VoiceSkillStatus { + const { snapshot } = useCoreState(); + const localAi = snapshot.runtime.localAi; + + const [voiceStatus, setVoiceStatus] = useState(null); + const [serverStatus, setServerStatus] = useState(null); + + const fetchStatuses = useCallback(async () => { + if (!isTauri()) return; + try { + const [vs, ss] = await Promise.all([openhumanVoiceStatus(), openhumanVoiceServerStatus()]); + setVoiceStatus(vs); + setServerStatus(ss); + } catch (err) { + console.debug('[voice-skill-status] status fetch failed, will retry on next poll:', err); + } + }, []); + + // Poll voice status every 3s (lighter than the panel's 2s — just for card state) + useEffect(() => { + void fetchStatuses(); + const id = window.setInterval(() => void fetchStatuses(), 3000); + return () => window.clearInterval(id); + }, [fetchStatuses]); + + const sttReady = useMemo(() => { + if (!voiceStatus) return false; + if (!voiceStatus.stt_available) return false; + // Also check Local AI asset state if available + if (localAi && localAi.stt_state !== 'ready') return false; + return true; + }, [voiceStatus, localAi]); + + return useMemo(() => { + // No data yet + if (!voiceStatus || !serverStatus) { + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Offline', + statusColor: 'text-stone-500', + ctaLabel: 'Enable', + ctaVariant: 'sage' as const, + sttModelMissing: false, + voiceStatus, + serverStatus, + }; + } + + // STT model not downloaded — needs setup + if (!sttReady) { + return { + connectionStatus: 'setup_required' as SkillConnectionStatus, + statusDot: 'bg-primary-400', + statusLabel: 'Setup', + statusColor: 'text-primary-400', + ctaLabel: 'Setup', + ctaVariant: 'primary' as const, + sttModelMissing: true, + voiceStatus, + serverStatus, + }; + } + + // Error + if (serverStatus.last_error) { + return { + connectionStatus: 'error' as SkillConnectionStatus, + statusDot: 'bg-coral-500', + statusLabel: 'Error', + statusColor: 'text-coral-400', + ctaLabel: 'Retry', + ctaVariant: 'amber' as const, + sttModelMissing: false, + voiceStatus, + serverStatus, + }; + } + + // Active states: recording, transcribing, or idle (server running) + if (serverStatus.state === 'recording' || serverStatus.state === 'transcribing') { + return { + connectionStatus: 'connecting' as SkillConnectionStatus, + statusDot: 'bg-amber-500 animate-pulse', + statusLabel: serverStatus.state === 'recording' ? 'Recording' : 'Transcribing', + statusColor: 'text-amber-400', + ctaLabel: 'Manage', + ctaVariant: 'primary' as const, + sttModelMissing: false, + voiceStatus, + serverStatus, + }; + } + + if (serverStatus.state === 'idle') { + return { + connectionStatus: 'connected' as SkillConnectionStatus, + statusDot: 'bg-sage-500', + statusLabel: 'Active', + statusColor: 'text-sage-400', + ctaLabel: 'Manage', + ctaVariant: 'primary' as const, + sttModelMissing: false, + voiceStatus, + serverStatus, + }; + } + + // Stopped + return { + connectionStatus: 'offline' as SkillConnectionStatus, + statusDot: 'bg-stone-400', + statusLabel: 'Stopped', + statusColor: 'text-stone-500', + ctaLabel: 'Enable', + ctaVariant: 'sage' as const, + sttModelMissing: false, + voiceStatus, + serverStatus, + }; + }, [voiceStatus, serverStatus, sttReady]); +} diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 649cce196..3de88dab9 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -2,11 +2,17 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import ChannelSetupModal from '../components/channels/ChannelSetupModal'; +import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal'; +import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal'; import { SKILL_ICONS, type SkillListEntry } from '../components/skills/shared'; import UnifiedSkillCard, { ThirdPartySkillCard } from '../components/skills/SkillCard'; import SkillCategoryFilter, { type SkillCategory } from '../components/skills/SkillCategoryFilter'; import SkillSearchBar from '../components/skills/SkillSearchBar'; import SkillSetupModal from '../components/skills/SkillSetupModal'; +import VoiceSetupModal from '../components/skills/VoiceSetupModal'; +import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocompleteSkillStatus'; +import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligence/useScreenIntelligenceSkillStatus'; +import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus'; import { useChannelDefinitions } from '../hooks/useChannelDefinitions'; import { useAvailableSkills } from '../lib/skills/hooks'; import { installSkill } from '../lib/skills/skillsApi'; @@ -147,6 +153,12 @@ export default function Skills() { const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false); const [channelModalDef, setChannelModalDef] = useState(null); const [installing, setInstalling] = useState(null); + const [screenIntelligenceModalOpen, setScreenIntelligenceModalOpen] = useState(false); + const [autocompleteModalOpen, setAutocompleteModalOpen] = useState(false); + const [voiceModalOpen, setVoiceModalOpen] = useState(false); + const screenIntelligenceStatus = useScreenIntelligenceSkillStatus(); + const autocompleteStatus = useAutocompleteSkillStatus(); + const voiceStatus = useVoiceSkillStatus(); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('All'); @@ -318,6 +330,90 @@ export default function Skills() {
{items.map(item => { if (item.kind === 'builtin') { + // Screen Intelligence gets a state-aware card + if (item.id === 'screen-intelligence') { + return ( + { + if (screenIntelligenceStatus.platformUnsupported) { + navigate(item.route!); + return; + } + if ( + screenIntelligenceStatus.connectionStatus === 'connected' || + screenIntelligenceStatus.connectionStatus === 'disconnected' + ) { + navigate(item.route!); + return; + } + setScreenIntelligenceModalOpen(true); + }} + /> + ); + } + // Text Auto-Complete gets a state-aware card + if (item.id === 'text-autocomplete') { + return ( + { + if ( + autocompleteStatus.platformUnsupported || + autocompleteStatus.connectionStatus === 'connected' || + autocompleteStatus.connectionStatus === 'disconnected' + ) { + navigate(item.route!); + return; + } + setAutocompleteModalOpen(true); + }} + /> + ); + } + // Voice Intelligence gets a state-aware card + if (item.id === 'voice-stt') { + return ( + { + if ( + voiceStatus.connectionStatus === 'connected' || + voiceStatus.connectionStatus === 'connecting' || + voiceStatus.connectionStatus === 'disconnected' + ) { + navigate(item.route!); + return; + } + setVoiceModalOpen(true); + }} + /> + ); + } return ( setChannelModalDef(null)} /> )} + + {screenIntelligenceModalOpen && ( + setScreenIntelligenceModalOpen(false)} + initialStep={screenIntelligenceStatus.allPermissionsGranted ? 'enable' : 'permissions'} + /> + )} + + {autocompleteModalOpen && ( + setAutocompleteModalOpen(false)} /> + )} + + {voiceModalOpen && ( + setVoiceModalOpen(false)} skillStatus={voiceStatus} /> + )}
); }