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:
Cyrus Gray
2026-04-10 18:22:19 +05:30
committed by GitHub
parent aee9c52e88
commit 3a2e026346
13 changed files with 1657 additions and 435 deletions
@@ -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">&bull;</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]);
}
+111
View File
@@ -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>
);
}