mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(billing): redesign subscription cards and polish billing page UI (#492)
* feat(skills): state-aware cards and setup modals for built-in skills Replace static "Settings" buttons on Screen Intelligence, Text Auto-Complete, and Voice Intelligence skill cards with live status dots, labels, and dynamic CTA buttons (Enable/Setup/Manage/Retry) matching third-party skill UX. Each built-in skill gets: - A status hook deriving card state from core RPC snapshots - A setup/enable modal with step-by-step flows (permissions, enable, success) - Escape key + aria dialog attributes for accessibility Screen Intelligence: permission grant flow → enable → success Text Auto-Complete: one-click enable → success Voice Intelligence: STT model check → enable voice server → success * fix(billing): redesign subscription cards and polish full billing page UI - Redesign plan cards with clear visual hierarchy: name/tagline left, prominent price right, vertical feature checklist with check/X icons, full-width CTA buttons - Add "Popular" badge to Basic plan with accent border and shadow - Add taglines and rewrite features to user-friendly language - Remove confusing technical pills (monthly budget, 7-day cycle, 10-hour cap, discount %) - Hide "Premium-usage discount: 0%" pill for free users - Remove redundant "Why upgrade?" section - Fix double padding (SubscriptionPlans px-4/mx-4 and AutoRecharge px-4 inside parent p-4) - Fix "5-hour cap" label to "10-hour cap" and hide when both values are zero - Fix progress bar background from dark stone-700/60 to light stone-200 - Shorten verbose copy across Current Plan header, divider, and Pay as You Go description * style: apply prettier formatting
This commit is contained in:
@@ -413,8 +413,8 @@ const BillingPanel = () => {
|
||||
)}
|
||||
<p className="text-xs text-stone-500">
|
||||
{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.'}
|
||||
</p>
|
||||
{currentPlan && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
@@ -433,12 +433,10 @@ const BillingPanel = () => {
|
||||
10-hour cap: {formatUsdAmount(currentPlan.fiveHourCapUsd)}
|
||||
</span>
|
||||
)}
|
||||
{currentPlanMeta && (
|
||||
<>
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Premium-usage discount: {currentPlanMeta.discountPercent}%
|
||||
</span>
|
||||
</>
|
||||
{currentPlanMeta && currentPlanMeta.discountPercent > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Premium-usage discount: {currentPlanMeta.discountPercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -457,7 +455,7 @@ const BillingPanel = () => {
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className="flex-1 h-px bg-stone-200" />
|
||||
<span className="text-xs text-stone-400 whitespace-nowrap">
|
||||
Or subscribe for recurring included usage + discounts
|
||||
Or subscribe for included usage + discounts
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-stone-200" />
|
||||
</div>
|
||||
@@ -506,52 +504,6 @@ const BillingPanel = () => {
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onAddCard={handleAddCard}
|
||||
/>
|
||||
|
||||
{/* ── Why upgrade? ───────────────────────────────────── */}
|
||||
<div className="pt-2">
|
||||
<div className="rounded-xl bg-gradient-to-br from-primary-500/10 to-sage-500/10 border border-primary-500/20 p-4">
|
||||
<h3 className="text-sm font-semibold text-stone-900 mb-2">Why upgrade?</h3>
|
||||
<ul className="space-y-1.5 text-xs text-stone-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Higher tiers increase your premium-usage discount and included usage every cycle
|
||||
</span>
|
||||
</li>
|
||||
{currentTier === 'FREE' && (
|
||||
<li className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Annual billing lowers the effective monthly price, and top-ups let you keep
|
||||
going when usage spikes
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,330 +71,328 @@ const AutoRechargeSection = ({
|
||||
onDeleteCard,
|
||||
onAddCard,
|
||||
}: AutoRechargeSectionProps) => (
|
||||
<div className="px-4 pt-2">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-900">Auto-Recharge Credits</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
Automatically top up when your balance runs low
|
||||
</p>
|
||||
</div>
|
||||
{arLoading ? (
|
||||
<div className="w-10 h-5 rounded-full bg-stone-700/60 animate-pulse" />
|
||||
) : (
|
||||
<button
|
||||
onClick={onArToggle}
|
||||
disabled={arSaving}
|
||||
role="switch"
|
||||
aria-checked={arSettings?.enabled ?? false}
|
||||
aria-label="Toggle auto-recharge"
|
||||
className={`relative w-10 h-5 rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 ${
|
||||
arSaving ? 'opacity-50 cursor-not-allowed' : ''
|
||||
} ${arSettings?.enabled ? 'bg-primary-500' : 'bg-stone-600'}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||
arSettings?.enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-900">Auto-Recharge Credits</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
Automatically top up when your balance runs low
|
||||
</p>
|
||||
</div>
|
||||
{arLoading ? (
|
||||
<div className="w-10 h-5 rounded-full bg-stone-700/60 animate-pulse" />
|
||||
) : (
|
||||
<button
|
||||
onClick={onArToggle}
|
||||
disabled={arSaving}
|
||||
role="switch"
|
||||
aria-checked={arSettings?.enabled ?? false}
|
||||
aria-label="Toggle auto-recharge"
|
||||
className={`relative w-10 h-5 rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 ${
|
||||
arSaving ? 'opacity-50 cursor-not-allowed' : ''
|
||||
} ${arSettings?.enabled ? 'bg-primary-500' : 'bg-stone-600'}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||
arSettings?.enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{arError && (
|
||||
<div className="mx-3 mb-2 flex items-start gap-2 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-2">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-coral-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
{/* Error banner */}
|
||||
{arError && (
|
||||
<div className="mx-3 mb-2 flex items-start gap-2 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-2">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-coral-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-coral-300 leading-relaxed">{arError}</p>
|
||||
<button
|
||||
onClick={() => setArError(null)}
|
||||
className="ml-auto text-coral-400 hover:text-coral-300 flex-shrink-0"
|
||||
aria-label="Dismiss error">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-coral-300 leading-relaxed">{arError}</p>
|
||||
<button
|
||||
onClick={() => setArError(null)}
|
||||
className="ml-auto text-coral-400 hover:text-coral-300 flex-shrink-0"
|
||||
aria-label="Dismiss error">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings — only shown when enabled */}
|
||||
{!arLoading && arSettings?.enabled && (
|
||||
<div className="border-t border-stone-200 px-3 pt-3 pb-2 space-y-3">
|
||||
{/* Status row */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{arSettings.inFlight && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||
<svg className="w-2.5 h-2.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Recharge in progress
|
||||
</span>
|
||||
)}
|
||||
{arSettings.spentThisWeekUsd > 0 && (
|
||||
<span className="text-[10px] text-stone-400">
|
||||
${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd} used this
|
||||
week
|
||||
</span>
|
||||
)}
|
||||
{arSettings.lastRechargeAt && (
|
||||
<span className="text-[10px] text-stone-500">
|
||||
Last recharged{' '}
|
||||
{new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last error from recharge attempt */}
|
||||
{arSettings.lastError && (
|
||||
<div className="flex items-start gap-1.5 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-2">
|
||||
<svg
|
||||
className="w-3 h-3 text-coral-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
{/* Settings — only shown when enabled */}
|
||||
{!arLoading && arSettings?.enabled && (
|
||||
<div className="border-t border-stone-200 px-3 pt-3 pb-2 space-y-3">
|
||||
{/* Status row */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{arSettings.inFlight && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||
<svg className="w-2.5 h-2.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[10px] text-coral-300">
|
||||
Last recharge failed: {arSettings.lastError}
|
||||
</p>
|
||||
</div>
|
||||
Recharge in progress
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Trigger threshold */}
|
||||
<div>
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Recharge when balance drops below</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{THRESHOLD_OPTIONS.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setArThreshold(v)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
arThreshold === v
|
||||
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
|
||||
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recharge amount */}
|
||||
<div>
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Add this amount</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{RECHARGE_OPTIONS.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setArAmount(v)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
arAmount === v
|
||||
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
|
||||
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly limit */}
|
||||
<div>
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Weekly spending limit</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{WEEKLY_LIMIT_OPTIONS.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setArWeeklyLimit(v)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
arWeeklyLimit === v
|
||||
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
|
||||
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation hint */}
|
||||
{arAmount <= arThreshold && (
|
||||
<p className="text-[10px] text-amber-400">
|
||||
Recharge amount should be greater than the trigger threshold.
|
||||
</p>
|
||||
{arSettings.spentThisWeekUsd > 0 && (
|
||||
<span className="text-[10px] text-stone-400">
|
||||
${arSettings.spentThisWeekUsd.toFixed(2)} of ${arSettings.weeklyLimitUsd} used this
|
||||
week
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
{arDirty && (
|
||||
<button
|
||||
onClick={onArSave}
|
||||
disabled={arSaving || arAmount <= arThreshold}
|
||||
className={`w-full py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
arSaving || arAmount <= arThreshold
|
||||
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
}`}>
|
||||
{arSaving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
{arSettings.lastRechargeAt && (
|
||||
<span className="text-[10px] text-stone-500">
|
||||
Last recharged{' '}
|
||||
{new Date(arSettings.lastRechargeAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment methods */}
|
||||
<div className="border-t border-stone-200 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-medium text-stone-600">Payment Methods</p>
|
||||
<button
|
||||
onClick={onAddCard}
|
||||
className="text-[11px] text-primary-400 hover:text-primary-300 font-medium transition-colors">
|
||||
+ Add card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cardsLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
{[0, 1].map(i => (
|
||||
<div key={i} className="h-9 rounded-lg bg-stone-700/30 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-stone-50 border border-stone-200 p-2.5">
|
||||
{/* Last error from recharge attempt */}
|
||||
{arSettings.lastError && (
|
||||
<div className="flex items-start gap-1.5 rounded-lg bg-coral-500/10 border border-coral-500/20 px-2.5 py-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500 flex-shrink-0"
|
||||
className="w-3 h-3 text-coral-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-stone-500">
|
||||
No saved cards. Add one to enable auto-recharge.
|
||||
<p className="text-[10px] text-coral-300">
|
||||
Last recharge failed: {arSettings.lastError}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{cards.map(card => {
|
||||
const isDeleting = deletingCardId === card.id;
|
||||
const isSettingDefault = settingDefaultId === card.id;
|
||||
const isConfirming = confirmDeleteId === card.id;
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center gap-2 rounded-lg bg-stone-50 border border-stone-200 px-2.5 py-2">
|
||||
{/* Card icon */}
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Card info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-stone-900 font-medium">
|
||||
{cardBrandLabel(card.brand)} ••••{card.last4}
|
||||
</span>
|
||||
{card.isDefault && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30 font-medium">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-stone-500 mt-0.5">
|
||||
Expires {String(card.expMonth).padStart(2, '0')}/
|
||||
{String(card.expYear).slice(-2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!card.isDefault && (
|
||||
<button
|
||||
onClick={() => onSetDefault(card.id)}
|
||||
disabled={!!settingDefaultId || !!deletingCardId}
|
||||
className="text-[10px] text-stone-500 hover:text-stone-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
|
||||
{isSettingDefault ? '…' : 'Set default'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onDeleteCard(card.id)}
|
||||
disabled={isDeleting}
|
||||
className="text-[10px] text-coral-400 hover:text-coral-300 font-medium transition-colors disabled:opacity-40 px-1.5 py-1">
|
||||
{isDeleting ? '…' : 'Confirm'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="text-[10px] text-stone-500 hover:text-stone-400 transition-colors px-1 py-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(card.id)}
|
||||
disabled={isDeleting || !!settingDefaultId}
|
||||
className="text-[10px] text-stone-500 hover:text-coral-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Trigger threshold */}
|
||||
<div>
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Recharge when balance drops below</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{THRESHOLD_OPTIONS.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setArThreshold(v)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
arThreshold === v
|
||||
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
|
||||
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recharge amount */}
|
||||
<div>
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Add this amount</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{RECHARGE_OPTIONS.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setArAmount(v)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
arAmount === v
|
||||
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
|
||||
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly limit */}
|
||||
<div>
|
||||
<p className="text-[11px] text-stone-400 mb-1.5">Weekly spending limit</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{WEEKLY_LIMIT_OPTIONS.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setArWeeklyLimit(v)}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
arWeeklyLimit === v
|
||||
? 'bg-primary-500/20 text-primary-400 border-primary-500/40'
|
||||
: 'bg-stone-100 text-stone-500 border-stone-200 hover:text-stone-700'
|
||||
}`}>
|
||||
${v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation hint */}
|
||||
{arAmount <= arThreshold && (
|
||||
<p className="text-[10px] text-amber-400">
|
||||
Recharge amount should be greater than the trigger threshold.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
{arDirty && (
|
||||
<button
|
||||
onClick={onArSave}
|
||||
disabled={arSaving || arAmount <= arThreshold}
|
||||
className={`w-full py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
arSaving || arAmount <= arThreshold
|
||||
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
}`}>
|
||||
{arSaving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment methods */}
|
||||
<div className="border-t border-stone-200 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[11px] font-medium text-stone-600">Payment Methods</p>
|
||||
<button
|
||||
onClick={onAddCard}
|
||||
className="text-[11px] text-primary-400 hover:text-primary-300 font-medium transition-colors">
|
||||
+ Add card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cardsLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
{[0, 1].map(i => (
|
||||
<div key={i} className="h-9 rounded-lg bg-stone-700/30 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-stone-50 border border-stone-200 p-2.5">
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[11px] text-stone-500">
|
||||
No saved cards. Add one to enable auto-recharge.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{cards.map(card => {
|
||||
const isDeleting = deletingCardId === card.id;
|
||||
const isSettingDefault = settingDefaultId === card.id;
|
||||
const isConfirming = confirmDeleteId === card.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center gap-2 rounded-lg bg-stone-50 border border-stone-200 px-2.5 py-2">
|
||||
{/* Card icon */}
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Card info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-stone-900 font-medium">
|
||||
{cardBrandLabel(card.brand)} ••••{card.last4}
|
||||
</span>
|
||||
{card.isDefault && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30 font-medium">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-stone-500 mt-0.5">
|
||||
Expires {String(card.expMonth).padStart(2, '0')}/
|
||||
{String(card.expYear).slice(-2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!card.isDefault && (
|
||||
<button
|
||||
onClick={() => onSetDefault(card.id)}
|
||||
disabled={!!settingDefaultId || !!deletingCardId}
|
||||
className="text-[10px] text-stone-500 hover:text-stone-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
|
||||
{isSettingDefault ? '…' : 'Set default'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onDeleteCard(card.id)}
|
||||
disabled={isDeleting}
|
||||
className="text-[10px] text-coral-400 hover:text-coral-300 font-medium transition-colors disabled:opacity-40 px-1.5 py-1">
|
||||
{isDeleting ? '…' : 'Confirm'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="text-[10px] text-stone-500 hover:text-stone-400 transition-colors px-1 py-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(card.id)}
|
||||
disabled={isDeleting || !!settingDefaultId}
|
||||
className="text-[10px] text-stone-500 hover:text-coral-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed px-1.5 py-1">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
|
||||
{teamUsage ? (
|
||||
teamUsage.cycleBudgetUsd > 0 ? (
|
||||
<>
|
||||
<div className="h-1.5 bg-stone-700/60 rounded-full overflow-hidden mb-2">
|
||||
<div className="h-1.5 bg-stone-200 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
teamUsage.remainingUsd <= 0
|
||||
@@ -39,11 +39,13 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-[11px] text-stone-500">
|
||||
5-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
|
||||
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
{((teamUsage.cycleLimit5hr ?? 0) > 0 || (teamUsage.fiveHourCapUsd ?? 0) > 0) && (
|
||||
<span className="text-[11px] text-stone-500">
|
||||
10-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
|
||||
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-stone-500 ml-auto">
|
||||
Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -63,7 +65,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
|
||||
</div>
|
||||
)
|
||||
) : isLoadingCredits ? (
|
||||
<div className="h-1.5 w-full rounded-full bg-stone-700/60 animate-pulse" />
|
||||
<div className="h-1.5 w-full rounded-full bg-stone-200 animate-pulse" />
|
||||
) : (
|
||||
<p className="text-xs text-stone-500">Unable to load usage data</p>
|
||||
)}
|
||||
|
||||
@@ -100,8 +100,7 @@ const PayAsYouGoCard = ({
|
||||
)}
|
||||
|
||||
<p className="mb-3 text-[11px] text-stone-500">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* Top-up buttons */}
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex items-center justify-center gap-2 px-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (paymentMethod !== 'crypto') setBillingInterval('monthly');
|
||||
@@ -57,7 +51,7 @@ const SubscriptionPlans = ({
|
||||
</div>
|
||||
|
||||
{/* Plan tier cards */}
|
||||
<div className="space-y-2 px-4">
|
||||
<div className="space-y-3">
|
||||
{PLANS.map(plan => {
|
||||
const isCurrent = plan.tier === currentTier;
|
||||
const isUpgrade = checkIsUpgrade(plan.tier, currentTier);
|
||||
@@ -67,76 +61,105 @@ const SubscriptionPlans = ({
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className={`rounded-2xl border p-3 transition-all ${
|
||||
isCurrent ? 'border-primary-500/40 bg-primary-500/5' : 'border-stone-200 bg-white'
|
||||
className={`relative rounded-2xl border p-4 transition-all ${
|
||||
plan.recommended
|
||||
? 'border-primary-500 bg-primary-500/5 shadow-sm'
|
||||
: isCurrent
|
||||
? 'border-primary-500/40 bg-primary-500/5'
|
||||
: 'border-stone-200 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-semibold text-stone-900">{plan.name}</h4>
|
||||
{plan.features.map(f => (
|
||||
<span key={f.text} className="text-xs text-stone-600">
|
||||
<span className="text-stone-500 mx-1">•</span>
|
||||
{f.text}
|
||||
</span>
|
||||
))}
|
||||
{isCurrent && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{savings && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
|
||||
Save {savings}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-stone-900">
|
||||
{/* Popular badge */}
|
||||
{plan.recommended && (
|
||||
<span className="absolute -top-2.5 left-4 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide rounded-full bg-primary-500 text-white">
|
||||
Popular
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Header: name + tagline on left, price on right */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-stone-900">{plan.name}</h4>
|
||||
{plan.tagline && <p className="text-xs text-stone-400 mt-0.5">{plan.tagline}</p>}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="flex items-baseline gap-0.5 justify-end">
|
||||
<span className="text-2xl font-bold text-stone-900">
|
||||
{displayPrice(plan, billingInterval)}
|
||||
</span>
|
||||
{plan.tier !== 'FREE' && <span className="text-xs text-stone-400">/mo</span>}
|
||||
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
|
||||
<span className="text-xs text-stone-500 ml-1">
|
||||
(billed ${plan.annualPrice}/yr)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{plan.monthlyBudgetUsd > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Included monthly value: {formatUsdAmount(plan.monthlyBudgetUsd)}
|
||||
</span>
|
||||
)}
|
||||
{plan.weeklyBudgetUsd > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
7-day cycle: {formatUsdAmount(plan.weeklyBudgetUsd)}
|
||||
</span>
|
||||
)}
|
||||
{plan.fiveHourCapUsd > 0 && (
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
10-hour cap: {formatUsdAmount(plan.fiveHourCapUsd)}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
|
||||
Discount: {plan.discountPercent}%
|
||||
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">billed ${plan.annualPrice}/yr</p>
|
||||
)}
|
||||
{savings && (
|
||||
<span className="inline-block mt-1 px-2 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-500 border border-sage-500/30">
|
||||
Save {savings}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{isUpgrade && (
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-stone-100 my-3" />
|
||||
|
||||
{/* Feature list */}
|
||||
<ul className="space-y-2">
|
||||
{plan.features.map(f => (
|
||||
<li key={f.text} className="flex items-start gap-2">
|
||||
{f.included ? (
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-500 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-300 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className={`text-xs ${f.included ? 'text-stone-600' : 'text-stone-400'}`}>
|
||||
{f.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-4">
|
||||
{isCurrent ? (
|
||||
<div className="w-full py-2 text-center text-xs font-medium rounded-lg border border-primary-500/30 bg-primary-500/10 text-primary-500">
|
||||
Current Plan
|
||||
</div>
|
||||
) : isUpgrade ? (
|
||||
<button
|
||||
onClick={() => onUpgrade(plan.tier)}
|
||||
disabled={isPurchasing}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex-shrink-0 ${
|
||||
className={`w-full py-2 text-xs font-medium rounded-lg transition-colors ${
|
||||
isPurchasing
|
||||
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
? 'bg-stone-200 text-stone-400 cursor-not-allowed'
|
||||
: plan.recommended
|
||||
? 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
: 'bg-stone-900 hover:bg-stone-800 text-white'
|
||||
}`}>
|
||||
{isThisPurchasing ? 'Waiting...' : 'Upgrade'}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -145,7 +168,7 @@ const SubscriptionPlans = ({
|
||||
|
||||
{/* Payment confirmed banner */}
|
||||
{paymentConfirmed && (
|
||||
<div className="rounded-xl bg-sage-500/10 border border-sage-500/20 p-3 mx-4">
|
||||
<div className="rounded-xl bg-sage-500/10 border border-sage-500/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-400 flex-shrink-0"
|
||||
@@ -163,7 +186,7 @@ const SubscriptionPlans = ({
|
||||
|
||||
{/* Purchasing overlay message */}
|
||||
{isPurchasing && (
|
||||
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3 mx-4">
|
||||
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
@@ -188,7 +211,7 @@ const SubscriptionPlans = ({
|
||||
)}
|
||||
|
||||
{/* Pay with crypto toggle */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-stone-50 border border-stone-200 p-3 mx-4">
|
||||
<div className="flex items-center justify-between rounded-xl bg-stone-50 border border-stone-200 p-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-stone-900">Pay with Crypto</p>
|
||||
<p className="text-[11px] text-stone-400 mt-0.5">
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<Step>('enable');
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
const [enableError, setEnableError] = useState<string | null>(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(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ac-setup-title"
|
||||
className="w-full max-w-md mx-4 rounded-2xl bg-white shadow-xl overflow-hidden animate-fade-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M4 7h16M4 12h10m-10 5h7m10 0l3 3m0 0l3-3m-3 3v-8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="ac-setup-title" className="text-sm font-semibold text-stone-900">Text Auto-Complete</h2>
|
||||
<p className="text-xs text-stone-500">
|
||||
{step === 'enable' && 'Enable inline completions'}
|
||||
{step === 'success' && 'Ready to go'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{/* ─── Enable step ─── */}
|
||||
{step === 'enable' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
Text Auto-Complete suggests inline completions as you type across any app. Suggestions appear as an overlay you can accept with Tab.
|
||||
</p>
|
||||
|
||||
{!status?.platform_supported && status !== null && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
|
||||
Auto-complete is not supported on this platform.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Style preset</span>
|
||||
<span className="text-xs text-stone-500">Balanced (configurable later)</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Accept key</span>
|
||||
<span className="text-xs font-mono text-stone-500">Tab</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Debounce</span>
|
||||
<span className="text-xs text-stone-500">{status?.debounce_ms ?? 120}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enableError && (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
|
||||
{enableError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleEnable()}
|
||||
disabled={isEnabling || (status !== null && !status.platform_supported)}
|
||||
className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
|
||||
{isEnabling ? 'Enabling...' : 'Enable Auto-Complete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Success step ─── */}
|
||||
{step === 'success' && (
|
||||
<div className="space-y-4 text-center py-2">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-sage-50 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Auto-Complete is Active</h3>
|
||||
<p className="mt-1 text-xs text-stone-500 leading-relaxed">
|
||||
Start typing in any app and suggestions will appear as an inline overlay. Press Tab to accept.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToSettings}
|
||||
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-4 py-2.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||
Customize Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 transition-colors">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-white px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{granted ? (
|
||||
<svg className="w-4 h-4 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" strokeWidth={2} />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm text-stone-700">{label}</span>
|
||||
</div>
|
||||
{granted ? (
|
||||
<span className={`rounded-md border px-2 py-0.5 text-[10px] uppercase tracking-wide ${badgeColor}`}>
|
||||
Granted
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRequesting}
|
||||
onClick={onRequest}
|
||||
className="rounded-lg border border-primary-300 bg-primary-50 px-2.5 py-1 text-[11px] font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50 transition-colors">
|
||||
{isRequesting ? 'Opening...' : 'Grant'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 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<string | null>(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<Step>(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(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="si-setup-title"
|
||||
className="w-full max-w-md mx-4 rounded-2xl bg-white shadow-xl overflow-hidden animate-fade-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 5h18v12H3zM8 21h8m-4-4v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="si-setup-title" className="text-sm font-semibold text-stone-900">Screen Intelligence</h2>
|
||||
<p className="text-xs text-stone-500">
|
||||
{step === 'permissions' && 'Grant permissions'}
|
||||
{step === 'enable' && 'Enable the skill'}
|
||||
{step === 'success' && 'Ready to go'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{/* ─── Step 1: Permissions ─── */}
|
||||
{step === 'permissions' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
Screen Intelligence needs macOS permissions to capture your screen and provide context to your AI assistant.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<PermissionRow
|
||||
label="Screen Recording"
|
||||
value={status?.permissions.screen_recording ?? 'unknown'}
|
||||
onRequest={() => void requestPermission('screen_recording')}
|
||||
isRequesting={isRequestingPermissions}
|
||||
/>
|
||||
<PermissionRow
|
||||
label="Accessibility"
|
||||
value={status?.permissions.accessibility ?? 'unknown'}
|
||||
onRequest={() => void requestPermission('accessibility')}
|
||||
isRequesting={isRequestingPermissions}
|
||||
/>
|
||||
<PermissionRow
|
||||
label="Input Monitoring"
|
||||
value={status?.permissions.input_monitoring ?? 'unknown'}
|
||||
onRequest={() => void requestPermission('input_monitoring')}
|
||||
isRequesting={isRequestingPermissions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{anyDenied && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700 leading-relaxed">
|
||||
<p>After granting permissions in System Settings, click below to restart and pick up the changes.</p>
|
||||
{status?.permission_check_process_path && (
|
||||
<p className="mt-1 opacity-75 text-[10px]">
|
||||
macOS applies privacy to:{' '}
|
||||
<span className="font-mono break-all text-stone-600">
|
||||
{status.permission_check_process_path}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRestartSummary && (
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 p-3 text-xs text-sage-700">
|
||||
{lastRestartSummary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastError && (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{anyDenied ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshPermissionsWithRestart()}
|
||||
disabled={isRestartingCore}
|
||||
className="flex-1 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm font-medium text-amber-700 hover:bg-amber-100 disabled:opacity-50 transition-colors">
|
||||
{isRestartingCore ? 'Restarting...' : 'Restart & Refresh'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRestartingCore}
|
||||
className="flex-1 rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 disabled:opacity-50 transition-colors">
|
||||
Refresh Status
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Step 2: Enable ─── */}
|
||||
{step === 'enable' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 p-3 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-sage-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-xs text-sage-700">All permissions granted</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
Enable Screen Intelligence to continuously capture what's on your screen and feed useful context into your AI assistant's memory.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Capture mode</span>
|
||||
<span className="text-xs text-stone-500">All windows (configurable later)</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Vision model</span>
|
||||
<span className="text-xs text-stone-500">Enabled</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Panic hotkey</span>
|
||||
<span className="text-xs font-mono text-stone-500">{status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enableError && (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
|
||||
{enableError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleEnable()}
|
||||
disabled={isEnabling}
|
||||
className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
|
||||
{isEnabling ? 'Enabling...' : 'Enable Screen Intelligence'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Step 3: Success ─── */}
|
||||
{step === 'success' && (
|
||||
<div className="space-y-4 text-center py-2">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-sage-50 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Screen Intelligence is Enabled</h3>
|
||||
<p className="mt-1 text-xs text-stone-500 leading-relaxed">
|
||||
Screen Intelligence is now enabled. Start a session from the settings panel to begin capturing screen context for your AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToSettings}
|
||||
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-4 py-2.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||
Advanced Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 transition-colors">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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<Step>(sttModelMissing ? 'setup' : 'enable');
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
const [enableError, setEnableError] = useState<string | null>(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(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="voice-setup-title"
|
||||
className="w-full max-w-md mx-4 rounded-2xl bg-white shadow-xl overflow-hidden animate-fade-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="voice-setup-title" className="text-sm font-semibold text-stone-900">Voice Intelligence</h2>
|
||||
<p className="text-xs text-stone-500">
|
||||
{step === 'setup' && 'Model download required'}
|
||||
{step === 'enable' && 'Start voice server'}
|
||||
{step === 'success' && 'Ready to go'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{/* ─── Setup step: STT model missing ─── */}
|
||||
{step === 'setup' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div className="text-xs text-amber-700 leading-relaxed">
|
||||
<p className="font-medium">Speech-to-text model not ready</p>
|
||||
<p className="mt-1">Voice Intelligence requires a local Whisper model for transcription. Download it from the Local Model settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
Once the STT model is downloaded, you can return here to enable voice dictation and voice-driven AI chat.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToLocalModel}
|
||||
className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 transition-colors">
|
||||
Download STT Model
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Enable step ─── */}
|
||||
{step === 'enable' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 p-3 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-sage-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-xs text-sage-700">Speech-to-text model ready</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
Start the voice server to use dictation and voice-driven chat. Press the hotkey to toggle recording.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Hotkey</span>
|
||||
<span className="text-xs font-mono text-stone-500">{serverStatus?.hotkey ?? 'Fn'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
|
||||
<span className="text-sm text-stone-700">Activation</span>
|
||||
<span className="text-xs text-stone-500">{serverStatus?.activation_mode === 'push' ? 'Push-to-talk' : 'Tap to toggle'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enableError && (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
|
||||
{enableError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleEnable()}
|
||||
disabled={isEnabling}
|
||||
className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
|
||||
{isEnabling ? 'Starting...' : 'Start Voice Server'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Success step ─── */}
|
||||
{step === 'success' && (
|
||||
<div className="space-y-4 text-center py-2">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-sage-50 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-sage-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900">Voice Intelligence is Active</h3>
|
||||
<p className="mt-1 text-xs text-stone-500 leading-relaxed">
|
||||
Press <span className="font-mono font-medium">{serverStatus?.hotkey ?? 'Fn'}</span> to start dictating. Your voice will be transcribed and sent to your AI assistant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToSettings}
|
||||
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-4 py-2.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||
Customize Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 transition-colors">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<VoiceStatus | null>(null);
|
||||
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(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]);
|
||||
}
|
||||
@@ -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<ChannelDefinition | null>(null);
|
||||
const [installing, setInstalling] = useState<string | null>(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<SkillCategory>('All');
|
||||
@@ -318,6 +330,90 @@ export default function Skills() {
|
||||
<div className="space-y-2">
|
||||
{items.map(item => {
|
||||
if (item.kind === 'builtin') {
|
||||
// Screen Intelligence gets a state-aware card
|
||||
if (item.id === 'screen-intelligence') {
|
||||
return (
|
||||
<UnifiedSkillCard
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
statusDot={screenIntelligenceStatus.statusDot}
|
||||
statusLabel={screenIntelligenceStatus.statusLabel}
|
||||
statusColor={screenIntelligenceStatus.statusColor}
|
||||
ctaLabel={screenIntelligenceStatus.ctaLabel}
|
||||
ctaVariant={screenIntelligenceStatus.ctaVariant}
|
||||
onCtaClick={() => {
|
||||
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 (
|
||||
<UnifiedSkillCard
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
statusDot={autocompleteStatus.statusDot}
|
||||
statusLabel={autocompleteStatus.statusLabel}
|
||||
statusColor={autocompleteStatus.statusColor}
|
||||
ctaLabel={autocompleteStatus.ctaLabel}
|
||||
ctaVariant={autocompleteStatus.ctaVariant}
|
||||
onCtaClick={() => {
|
||||
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 (
|
||||
<UnifiedSkillCard
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
statusDot={voiceStatus.statusDot}
|
||||
statusLabel={voiceStatus.statusLabel}
|
||||
statusColor={voiceStatus.statusColor}
|
||||
ctaLabel={voiceStatus.ctaLabel}
|
||||
ctaVariant={voiceStatus.ctaVariant}
|
||||
onCtaClick={() => {
|
||||
if (
|
||||
voiceStatus.connectionStatus === 'connected' ||
|
||||
voiceStatus.connectionStatus === 'connecting' ||
|
||||
voiceStatus.connectionStatus === 'disconnected'
|
||||
) {
|
||||
navigate(item.route!);
|
||||
return;
|
||||
}
|
||||
setVoiceModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<UnifiedSkillCard
|
||||
key={item.id}
|
||||
@@ -379,6 +475,21 @@ export default function Skills() {
|
||||
{channelModalDef && (
|
||||
<ChannelSetupModal definition={channelModalDef} onClose={() => setChannelModalDef(null)} />
|
||||
)}
|
||||
|
||||
{screenIntelligenceModalOpen && (
|
||||
<ScreenIntelligenceSetupModal
|
||||
onClose={() => setScreenIntelligenceModalOpen(false)}
|
||||
initialStep={screenIntelligenceStatus.allPermissionsGranted ? 'enable' : 'permissions'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{autocompleteModalOpen && (
|
||||
<AutocompleteSetupModal onClose={() => setAutocompleteModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{voiceModalOpen && (
|
||||
<VoiceSetupModal onClose={() => setVoiceModalOpen(false)} skillStatus={voiceStatus} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user