Improve upsell flow and retire legacy overlay app (#473)

* fix(autocomplete): disable autocomplete feature by default

- Updated the default configuration for the Autocomplete feature to be disabled instead of enabled in both the frontend and backend configurations. This change aims to improve user experience by preventing unintended activations of the autocomplete functionality upon application startup.

* feat(overlay): enhance overlay window functionality and responsiveness

- Updated the OverlayApp component to dynamically adjust its size and position based on the overlay status (idle/active), improving user interaction.
- Introduced new constants for overlay dimensions and margins, enhancing visual consistency.
- Implemented hover effects to adjust opacity, providing better visual feedback.
- Refactored window resizing and positioning logic to ensure the overlay remains user-friendly and visually appealing across different scenarios.
- Updated macOS window level to NSScreenSaverWindowLevel for improved behavior in fullscreen and multi-space environments.

* feat(dependencies): add objc2-core-graphics and related packages

- Introduced `objc2-core-graphics` as a new dependency in the Cargo.toml for macOS support.
- Updated Cargo.lock to include `objc2-metal`, `block2`, and `libc` as dependencies for enhanced functionality.
- Refactored overlay window configuration to utilize `CGShieldingWindowLevel`, improving window behavior in macOS environments.

* refactor(billing): remove storage limits and update plan budgets

- Removed `storageLimitBytes` from the `PlanMeta` interface and all plan definitions, simplifying the billing structure.
- Updated the `Free` plan to have zero budgets for monthly and weekly usage, aligning with the new billing strategy.
- Adjusted the `BillingPanel` and related components to conditionally display budget information based on the updated plan values.
- Enhanced the `InferenceBudget` and `PayAsYouGoCard` components to reflect changes in budget handling and improve user messaging.
- Updated tests to ensure consistency with the new billing logic and removed references to storage limits.

* feat(upsell): enhance GlobalUpsellBanner and PayAsYouGoCard components

- Added the GlobalUpsellBanner component to the App, improving user visibility of upgrade options.
- Refactored PayAsYouGoCard to better handle credit balance calculations, separating promo and top-up credits for clarity.
- Updated the UpsellBanner styles for a more consistent visual presentation.
- Introduced normalization functions in creditsApi to ensure robust handling of credit balance data.
- Added tests for creditsApi to validate the normalization logic and prevent UI crashes with missing data.

* feat(upsell): reintroduce GlobalUpsellBanner in App and enhance UpsellBanner styling

- Added the GlobalUpsellBanner back into the App component to improve user visibility of upgrade options.
- Updated the UpsellBanner component to include a new `rounded` prop for customizable styling.
- Removed dismissible functionality from the GlobalUpsellBanner, streamlining the user experience.
- Enhanced visual presentation by adjusting CSS styles for better consistency.

* refactor(overlay): remove obsolete overlay files and configurations

- Deleted unused files including .gitignore, index.html, package.json, postcss.config.js, README.md, tailwind.config.js, tsconfig.json, vite.config.ts, and yarn.lock from the overlay directory.
- Removed all source files related to the overlay functionality, including App.tsx, main.tsx, parentCoreRpc.ts, styles.css, and various components.
- Cleaned up the src-tauri directory by removing configuration files, icons, and capabilities related to the overlay, streamlining the project structure.
- This commit enhances maintainability by eliminating legacy code and unused resources.

* refactor(rewards): move DISCORD_INVITE_URL to a separate utility file

- Refactored the Rewards component to import the DISCORD_INVITE_URL from a new links utility file, improving code organization and maintainability.
- Created a new links.ts file to centralize URL constants, enhancing clarity and reusability across the application.

* style(app): apply formatter hook fixes

* chore(dependencies): remove @heroicons/react from yarn.lock

- Deleted the entry for @heroicons/react@^2.2.0 from yarn.lock, streamlining dependency management and reducing potential conflicts.
This commit is contained in:
Steven Enamakel
2026-04-09 20:41:26 -07:00
committed by GitHub
parent 38eb934242
commit a1f8bc55c4
69 changed files with 379 additions and 13131 deletions
+15
View File
@@ -9,6 +9,7 @@ dependencies = [
"env_logger",
"log",
"objc2-app-kit",
"objc2-core-graphics",
"reqwest 0.12.28",
"semver",
"serde",
@@ -2495,10 +2496,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.11.0",
"block2",
"dispatch2",
"libc",
"objc2",
"objc2-core-foundation",
"objc2-io-surface",
"objc2-metal",
]
[[package]]
@@ -2574,6 +2578,17 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-metal"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
+1
View File
@@ -42,6 +42,7 @@ env_logger = "0.11"
[target.'cfg(target_os = "macos")'.dependencies]
objc2-app-kit = "0.3.2"
objc2-core-graphics = "0.3.2"
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
+5 -3
View File
@@ -17,7 +17,9 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use tauri_plugin_deep_link::DeepLinkExt;
#[cfg(target_os = "macos")]
use objc2_app_kit::{NSStatusWindowLevel, NSWindow, NSWindowCollectionBehavior};
use objc2_app_kit::{NSWindow, NSWindowCollectionBehavior};
#[cfg(target_os = "macos")]
use objc2_core_graphics::CGShieldingWindowLevel;
/// Tracks the currently registered dictation hotkey string so we can unregister it later.
struct DictationHotkeyState(Mutex<Vec<String>>);
@@ -103,9 +105,9 @@ fn configure_overlay_window_macos(window: &WebviewWindow) {
behavior.insert(NSWindowCollectionBehavior::FullScreenAuxiliary);
behavior.insert(NSWindowCollectionBehavior::CanJoinAllSpaces);
window.setCollectionBehavior(behavior);
window.setLevel(NSStatusWindowLevel);
window.setLevel((CGShieldingWindowLevel() + 1) as isize);
log::info!(
"[overlay] macOS overlay configured for all spaces/fullscreen auxiliary at status level"
"[overlay] macOS overlay configured for all spaces/fullscreen auxiliary at shielding+1 level"
);
},
Err(err) => {
+1 -1
View File
@@ -37,6 +37,7 @@ function App() {
<MeshGradient />
<div className="app-dotted-canvas relative z-10 flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto pb-16">
<GlobalUpsellBanner />
<AppRoutes />
</div>
<BottomTabBar />
@@ -45,7 +46,6 @@ function App() {
<OnboardingOverlay />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<GlobalUpsellBanner />
</ServiceBlockingGate>
</Router>
</SocketProvider>
@@ -22,7 +22,7 @@ import AppFilterSection from './autocomplete/AppFilterSection';
import CompletionStyleSection from './autocomplete/CompletionStyleSection';
const DEFAULT_CONFIG: AutocompleteConfig = {
enabled: true,
enabled: false,
debounce_ms: 120,
max_chars: 384,
style_preset: 'balanced',
@@ -18,7 +18,7 @@ import AutoRechargeSection from './billing/AutoRechargeSection';
import InferenceBudget from './billing/InferenceBudget';
import PayAsYouGoCard from './billing/PayAsYouGoCard';
import SubscriptionPlans from './billing/SubscriptionPlans';
import { buildPlanId, formatStorageLimit, formatUsdAmount, getPlanMeta } from './billingHelpers';
import { buildPlanId, formatUsdAmount, getPlanMeta } from './billingHelpers';
const log = createDebug('openhuman:billing-panel');
@@ -399,28 +399,32 @@ const BillingPanel = () => {
</p>
)}
<p className="text-xs text-stone-500">
Your subscription includes premium usage each cycle. Pay-as-you-go credits cover
overage after the included budget is consumed.
{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.'}
</p>
{currentPlan && (
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
Included monthly value: {formatUsdAmount(currentPlan.monthlyBudgetUsd)}
</span>
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
7-day cycle budget: {formatUsdAmount(currentPlan.weeklyBudgetUsd)}
</span>
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
10-hour cap: {formatUsdAmount(currentPlan.fiveHourCapUsd)}
</span>
{currentPlan.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(currentPlan.monthlyBudgetUsd)}
</span>
)}
{currentPlan.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 budget: {formatUsdAmount(currentPlan.weeklyBudgetUsd)}
</span>
)}
{currentPlan.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(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>
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
Storage: {formatStorageLimit(currentPlanMeta.storageLimitBytes)}
</span>
</>
)}
</div>
@@ -440,7 +444,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 included usage + discounts
Or subscribe for recurring included usage + discounts
</span>
<div className="flex-1 h-px bg-stone-200" />
</div>
@@ -27,7 +27,9 @@ describe('PLANS', () => {
expect(free.monthlyPrice).toBe(0);
expect(free.annualPrice).toBe(0);
expect(free.discountPercent).toBe(0);
expect(free.weeklyBudgetUsd).toBe(0.5);
expect(free.monthlyBudgetUsd).toBe(0);
expect(free.weeklyBudgetUsd).toBe(0);
expect(free.fiveHourCapUsd).toBe(0);
});
it('should have BASIC plan aligned with backend config', () => {
@@ -141,7 +143,6 @@ describe('displayPrice', () => {
weeklyBudgetUsd: 25,
fiveHourCapUsd: 7.5,
discountPercent: 30,
storageLimitBytes: 50 * 1024 * 1024 * 1024,
features: [],
};
expect(displayPrice(custom, 'monthly')).toBe('$50');
@@ -187,7 +188,6 @@ describe('annualSavings', () => {
weeklyBudgetUsd: 5,
fiveHourCapUsd: 1.5,
discountPercent: 20,
storageLimitBytes: 1024,
features: [],
};
expect(annualSavings(noSavings, 'annual')).toBeNull();
@@ -203,7 +203,6 @@ describe('annualSavings', () => {
weeklyBudgetUsd: 50,
fiveHourCapUsd: 15,
discountPercent: 40,
storageLimitBytes: 1024,
features: [],
};
expect(annualSavings(bigDiscount, 'annual')).toBe(50);
@@ -12,42 +12,56 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
{isLoadingCredits && <span className="text-[10px] text-stone-500">Loading</span>}
{teamUsage && !isLoadingCredits && (
<span className="text-xs text-stone-400">
${teamUsage.remainingUsd.toFixed(2)} / ${teamUsage.cycleBudgetUsd.toFixed(2)} remaining
{teamUsage.cycleBudgetUsd > 0
? `$${teamUsage.remainingUsd.toFixed(2)} / $${teamUsage.cycleBudgetUsd.toFixed(2)} remaining`
: 'No recurring plan budget'}
</span>
)}
</div>
{teamUsage ? (
<>
<div className="h-1.5 bg-stone-700/60 rounded-full overflow-hidden mb-2">
<div
className={`h-full rounded-full transition-all duration-300 ${
teamUsage.remainingUsd <= 0
? 'bg-coral-500'
: teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{
width: `${Math.min(100, (teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100)}%`,
}}
/>
</div>
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] text-stone-500">
5-hour cap: ${teamUsage.cycleLimit5hr.toFixed(2)} / $
{teamUsage.fiveHourCapUsd.toFixed(2)}
</span>
<span className="text-[11px] text-stone-500">
Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
</span>
</div>
{teamUsage.remainingUsd <= 0 && (
<p className="text-[11px] text-coral-400 mt-1.5">
Included subscription usage is exhausted. Top up credits to continue using AI features
without waiting for the next cycle.
teamUsage.cycleBudgetUsd > 0 ? (
<>
<div className="h-1.5 bg-stone-700/60 rounded-full overflow-hidden mb-2">
<div
className={`h-full rounded-full transition-all duration-300 ${
teamUsage.remainingUsd <= 0
? 'bg-coral-500'
: teamUsage.remainingUsd / teamUsage.cycleBudgetUsd < 0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{
width: `${Math.min(
100,
(teamUsage.remainingUsd / teamUsage.cycleBudgetUsd) * 100
)}%`,
}}
/>
</div>
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] text-stone-500">
5-hour cap: ${teamUsage.cycleLimit5hr.toFixed(2)} / $
{teamUsage.fiveHourCapUsd.toFixed(2)}
</span>
<span className="text-[11px] text-stone-500">
Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
</span>
</div>
{teamUsage.remainingUsd <= 0 && (
<p className="text-[11px] text-coral-400 mt-1.5">
Included subscription usage is exhausted. Top up credits to continue using AI features
without waiting for the next cycle.
</p>
)}
</>
) : (
<div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5">
<p className="text-[11px] text-stone-600">
Your current plan does not include a recurring weekly inference budget. Usage is paid
from available credits instead.
</p>
)}
</>
</div>
)
) : isLoadingCredits ? (
<div className="h-1.5 w-full rounded-full bg-stone-700/60 animate-pulse" />
) : (
@@ -20,6 +20,11 @@ const PayAsYouGoCard = ({
onTopUp,
onBalanceRefresh,
}: PayAsYouGoCardProps) => {
const promoCredits = creditBalance?.balanceUsd ?? 0;
const topUpCredits = creditBalance?.topUpBalanceUsd ?? 0;
const topUpBaseline = creditBalance?.topUpBaselineUsd ?? null;
const availableCredits = promoCredits + topUpCredits;
// Coupon state (local — no need to share with other sections)
const [couponCode, setCouponCode] = useState('');
const [couponLoading, setCouponLoading] = useState(false);
@@ -63,41 +68,37 @@ const PayAsYouGoCard = ({
{/* Balance display */}
{creditBalance ? (
<div className="space-y-1.5 mb-3">
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">General credits</span>
<span className="text-xs font-medium text-stone-900">
${creditBalance.balanceUsd.toFixed(2)}
<div className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-2.5 py-2">
<span className="text-xs text-stone-500">Available credits</span>
<span className="text-sm font-semibold text-stone-900">
${availableCredits.toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">Signup + promo credits</span>
<span className="text-xs font-medium text-stone-900">${promoCredits.toFixed(2)}</span>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">Top-up credits</span>
<span className="text-xs font-medium text-stone-900">
${creditBalance.topUpBalanceUsd.toFixed(2)}
{creditBalance.topUpBaselineUsd != null && creditBalance.topUpBaselineUsd > 0 && (
<span className="text-stone-500 font-normal">
{' '}
/ ${creditBalance.topUpBaselineUsd.toFixed(2)}
</span>
${topUpCredits.toFixed(2)}
{topUpBaseline != null && topUpBaseline > 0 && (
<span className="text-stone-500 font-normal"> / ${topUpBaseline.toFixed(2)}</span>
)}
</span>
</div>
{creditBalance.topUpBaselineUsd != null && creditBalance.topUpBaselineUsd > 0 && (
{topUpBaseline != null && topUpBaseline > 0 && (
<div className="h-1 bg-stone-700/60 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
creditBalance.topUpBalanceUsd <= 0
topUpCredits <= 0
? 'bg-coral-500'
: creditBalance.topUpBalanceUsd / creditBalance.topUpBaselineUsd < 0.2
: topUpCredits / topUpBaseline < 0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{
width: `${Math.min(
100,
(creditBalance.topUpBalanceUsd / creditBalance.topUpBaselineUsd) * 100
)}%`,
}}
style={{ width: `${Math.min(100, (topUpCredits / topUpBaseline) * 100)}%` }}
/>
</div>
)}
@@ -113,8 +114,8 @@ const PayAsYouGoCard = ({
)}
<p className="mb-3 text-[11px] text-stone-500">
No subscription needed buy credits as you need them. If you have a subscription, your
included budget is consumed first.
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.
</p>
{/* Top-up buttons */}
@@ -3,7 +3,6 @@ import {
annualSavings,
isUpgrade as checkIsUpgrade,
displayPrice,
formatStorageLimit,
formatUsdAmount,
PLANS,
} from '../billingHelpers';
@@ -104,21 +103,24 @@ const SubscriptionPlans = ({
)}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<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>
<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>
<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>
{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}%
</span>
<span className="rounded-full border border-stone-200 bg-stone-50 px-2 py-1 text-[10px] text-stone-600">
Storage: {formatStorageLimit(plan.storageLimitBytes)}
</span>
</div>
</div>
@@ -15,7 +15,6 @@ export interface PlanMeta {
/** USD cap per 10-hour rolling inference window; amount scales with `tier` (FREE / BASIC / PRO). */
fiveHourCapUsd: number;
discountPercent: number;
storageLimitBytes: number;
features: PlanFeature[];
}
@@ -25,14 +24,14 @@ export const PLANS: PlanMeta[] = [
name: 'Free',
monthlyPrice: 0,
annualPrice: 0,
monthlyBudgetUsd: 1,
weeklyBudgetUsd: 0.5,
fiveHourCapUsd: 0.15,
monthlyBudgetUsd: 0,
weeklyBudgetUsd: 0,
fiveHourCapUsd: 0,
discountPercent: 0,
storageLimitBytes: 100 * 1024 * 1024,
features: [
{ text: 'Base access to integrations and inference', included: true },
{ text: 'Pay-as-you-go top-ups when included usage runs out', 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 },
],
},
@@ -45,7 +44,6 @@ export const PLANS: PlanMeta[] = [
weeklyBudgetUsd: 10,
fiveHourCapUsd: 3,
discountPercent: 20,
storageLimitBytes: 10 * 1024 * 1024 * 1024,
features: [
{ text: 'Higher included premium usage every billing cycle', included: true },
{
@@ -64,7 +62,6 @@ export const PLANS: PlanMeta[] = [
weeklyBudgetUsd: 100,
fiveHourCapUsd: 30,
discountPercent: 40,
storageLimitBytes: 200 * 1024 * 1024 * 1024,
features: [
{ text: 'Largest included premium usage allocation', included: true },
{ text: '40% premium-usage discount across integrations and inference', included: true },
@@ -113,11 +110,3 @@ export function formatUsdAmount(amount: number): string {
if (Number.isInteger(amount)) return `$${amount}`;
return `$${amount.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}`;
}
export function formatStorageLimit(bytes: number): string {
const gb = 1024 * 1024 * 1024;
const mb = 1024 * 1024;
if (bytes >= gb) return `${Math.round(bytes / gb)} GB`;
return `${Math.round(bytes / mb)} MB`;
}
@@ -1,60 +1,41 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useUsageState } from '../../hooks/useUsageState';
import UpsellBanner from './UpsellBanner';
import { dismissBanner, shouldShowBanner } from './upsellDismissState';
const COOLDOWN_WARNING_MS = 24 * 60 * 60 * 1000;
const COOLDOWN_UPGRADE_MS = 7 * 24 * 60 * 60 * 1000;
export default function GlobalUpsellBanner() {
const navigate = useNavigate();
const { teamUsage, isLoading, isAtLimit, isNearLimit, isFreeTier, usagePct10h, usagePct7d } =
useUsageState();
const [dismissed, setDismissed] = useState<Record<string, boolean>>({});
if (isLoading || !teamUsage) return null;
if (isAtLimit) {
const bannerId = 'global-upgrade';
if (!shouldShowBanner(bannerId, COOLDOWN_UPGRADE_MS) || dismissed[bannerId]) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[9997] px-4 pt-2">
<div className="relative z-20">
<UpsellBanner
variant="upgrade"
title="Usage limit reached"
message="Upgrade to continue chatting."
title="You've reached your usage limit"
message="Upgrade your plan or top up credits to continue"
ctaLabel="Upgrade"
rounded={false}
onCtaClick={() => navigate('/settings/billing')}
dismissible
onDismiss={() => {
dismissBanner(bannerId);
setDismissed(prev => ({ ...prev, [bannerId]: true }));
}}
/>
</div>
);
}
if (isNearLimit && isFreeTier) {
const bannerId = 'global-warning';
if (!shouldShowBanner(bannerId, COOLDOWN_WARNING_MS) || dismissed[bannerId]) return null;
const pct = Math.round(Math.max(usagePct10h, usagePct7d) * 100);
return (
<div className="fixed top-0 left-0 right-0 z-[9997] px-4 pt-2">
<div className="relative z-20">
<UpsellBanner
variant="warning"
title="Approaching usage limit"
message={`You've used ${pct}% of your usage limit. Upgrade for higher limits.`}
ctaLabel="Upgrade"
rounded={false}
onCtaClick={() => navigate('/settings/billing')}
dismissible
onDismiss={() => {
dismissBanner(bannerId);
setDismissed(prev => ({ ...prev, [bannerId]: true }));
}}
/>
</div>
);
+8 -6
View File
@@ -5,6 +5,7 @@ interface UpsellBannerProps {
ctaLabel?: string;
onCtaClick?: () => void;
dismissible?: boolean;
rounded?: boolean;
onDismiss?: () => void;
}
@@ -24,11 +25,11 @@ const VARIANT_STYLES = {
cta: 'bg-amber-500 hover:bg-amber-400 text-white',
},
upgrade: {
container: 'bg-coral-50 border-coral-200',
icon: 'text-coral-400',
title: 'text-coral-700',
text: 'text-coral-600',
cta: 'bg-coral-500 hover:bg-coral-400 text-white',
container: 'bg-amber-50 border-amber-200',
icon: 'text-amber-400',
title: 'text-amber-700',
text: 'text-amber-600',
cta: 'bg-amber-500 hover:bg-amber-400 text-white',
},
};
@@ -40,12 +41,13 @@ export default function UpsellBanner({
onCtaClick,
dismissible,
onDismiss,
rounded = true,
}: UpsellBannerProps) {
const styles = VARIANT_STYLES[variant];
return (
<div
className={`p-3 rounded-xl border flex items-center justify-between gap-3 ${styles.container}`}>
className={`p-3 ${rounded ? 'rounded-xl' : ''} border flex items-center justify-between gap-3 ${styles.container}`}>
<div className="flex items-center gap-2 min-w-0">
<svg
className={`w-4 h-4 flex-shrink-0 ${styles.icon}`}
@@ -43,7 +43,7 @@ export default function UsageLimitModal({
if (!open) return null;
const bodyText = isBudgetExhausted
? 'Your weekly inference budget is exhausted. Upgrade your plan or top up credits to continue.'
? 'Your included weekly inference budget is exhausted. Upgrade your plan or top up credits to continue.'
: `You've hit your 10-hour inference rate limit.${resetTime ? ` It resets ${formatResetTime(resetTime)}.` : ''} Upgrade for higher limits.`;
return (
@@ -78,9 +78,11 @@ export default function UsageLimitModal({
<li className="text-xs text-stone-600">
${nextPlan.fiveHourCapUsd.toFixed(2)} per 10-hour window
</li>
<li className="text-xs text-stone-600">
${nextPlan.weeklyBudgetUsd}/week included inference
</li>
{nextPlan.weeklyBudgetUsd > 0 && (
<li className="text-xs text-stone-600">
${nextPlan.weeklyBudgetUsd}/week included inference
</li>
)}
</ul>
</div>
)}
+98
View File
@@ -0,0 +1,98 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetCurrentPlan = vi.fn();
const mockGetTeamUsage = vi.fn();
vi.mock('../services/api/billingApi', () => ({
billingApi: { getCurrentPlan: () => mockGetCurrentPlan() },
}));
vi.mock('../services/api/creditsApi', () => ({
creditsApi: { getTeamUsage: () => mockGetTeamUsage() },
}));
describe('useUsageState', () => {
beforeEach(() => {
vi.resetModules();
mockGetCurrentPlan.mockReset();
mockGetTeamUsage.mockReset();
});
it('does not treat free users with zero recurring budget as exhausted', async () => {
const { useUsageState } = await import('./useUsageState');
mockGetCurrentPlan.mockResolvedValue({
plan: 'FREE',
hasActiveSubscription: false,
planExpiry: null,
subscription: null,
monthlyBudgetUsd: 0,
weeklyBudgetUsd: 0,
fiveHourCapUsd: 0,
});
mockGetTeamUsage.mockResolvedValue({
remainingUsd: 0,
cycleBudgetUsd: 0,
cycleLimit5hr: 0,
cycleLimit7day: 0,
fiveHourCapUsd: 0,
fiveHourResetsAt: null,
cycleStartDate: '2026-04-09T00:00:00.000Z',
cycleEndsAt: '2026-04-16T00:00:00.000Z',
bypassCycleLimit: false,
});
const { result } = renderHook(() => useUsageState());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isFreeTier).toBe(true);
expect(result.current.isBudgetExhausted).toBe(false);
expect(result.current.isRateLimited).toBe(false);
expect(result.current.isAtLimit).toBe(false);
expect(result.current.usagePct7d).toBe(0);
});
it('treats paid users with no remaining recurring budget as exhausted', async () => {
const { useUsageState } = await import('./useUsageState');
mockGetCurrentPlan.mockResolvedValue({
plan: 'BASIC',
hasActiveSubscription: true,
planExpiry: '2026-05-01T00:00:00.000Z',
subscription: {
id: 'sub_123',
status: 'active',
currentPeriodEnd: '2026-05-01T00:00:00.000Z',
quantity: 1,
},
monthlyBudgetUsd: 20,
weeklyBudgetUsd: 10,
fiveHourCapUsd: 3,
});
mockGetTeamUsage.mockResolvedValue({
remainingUsd: 0,
cycleBudgetUsd: 10,
cycleLimit5hr: 1,
cycleLimit7day: 10,
fiveHourCapUsd: 3,
fiveHourResetsAt: null,
cycleStartDate: '2026-04-09T00:00:00.000Z',
cycleEndsAt: '2026-04-16T00:00:00.000Z',
bypassCycleLimit: false,
});
const { result } = renderHook(() => useUsageState());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isBudgetExhausted).toBe(true);
expect(result.current.isAtLimit).toBe(true);
expect(result.current.usagePct7d).toBe(1);
});
});
+3 -1
View File
@@ -82,7 +82,9 @@ export function useUsageState(): UsageState {
? Math.min(1, (teamUsage.cycleBudgetUsd - teamUsage.remainingUsd) / teamUsage.cycleBudgetUsd)
: 0;
const isBudgetExhausted = teamUsage ? teamUsage.remainingUsd <= 0 : false;
const isBudgetExhausted = teamUsage
? teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0
: false;
const isRateLimited =
teamUsage !== null &&
+77 -21
View File
@@ -1,10 +1,20 @@
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window';
import {
currentMonitor,
getCurrentWindow,
LogicalPosition,
LogicalSize,
} from '@tauri-apps/api/window';
import { useEffect, useMemo, useRef, useState } from 'react';
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
const OVERLAY_WIDTH = 248;
const OVERLAY_HEIGHT = 228;
const OVERLAY_IDLE_WIDTH = 50;
const OVERLAY_IDLE_HEIGHT = 50;
const OVERLAY_ACTIVE_WIDTH = 224;
const OVERLAY_ACTIVE_HEIGHT = 208;
const OVERLAY_IDLE_MARGIN = 10;
const OVERLAY_ACTIVE_MARGIN = 20;
const OVERLAY_IDLE_OPACITY = 0.6;
const SCENARIO_THREE_TEXT = '"Noted. Need milk."';
type OverlayStatus = 'idle' | 'active';
@@ -68,20 +78,7 @@ function OverlayBubbleChip({ bubble }: { bubble: OverlayBubble }) {
export default function OverlayApp() {
const [scenario, setScenario] = useState<OverlayScenario>(1);
useEffect(() => {
const appWindow = getCurrentWindow();
const size = new LogicalSize(OVERLAY_WIDTH, OVERLAY_HEIGHT);
void appWindow.setSize(size).catch(error => {
console.warn('[overlay] failed to resize overlay window', error);
});
void appWindow.setMinSize(size).catch(error => {
console.warn('[overlay] failed to set overlay min size', error);
});
void appWindow.setMaxSize(size).catch(error => {
console.warn('[overlay] failed to set overlay max size', error);
});
}, []);
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
@@ -99,6 +96,51 @@ export default function OverlayApp() {
const status: OverlayStatus = scenario === 1 ? 'idle' : 'active';
useEffect(() => {
const appWindow = getCurrentWindow();
const isActive = status === 'active';
const width = isActive ? OVERLAY_ACTIVE_WIDTH : OVERLAY_IDLE_WIDTH;
const height = isActive ? OVERLAY_ACTIVE_HEIGHT : OVERLAY_IDLE_HEIGHT;
const margin = isActive ? OVERLAY_ACTIVE_MARGIN : OVERLAY_IDLE_MARGIN;
const size = new LogicalSize(width, height);
const updateWindowFrame = async () => {
try {
await appWindow.setSize(size);
} catch (error) {
console.warn('[overlay] failed to resize overlay window', error);
}
try {
await appWindow.setMinSize(size);
} catch (error) {
console.warn('[overlay] failed to set overlay min size', error);
}
try {
await appWindow.setMaxSize(size);
} catch (error) {
console.warn('[overlay] failed to set overlay max size', error);
}
try {
const monitor = await currentMonitor();
if (!monitor) {
console.warn('[overlay] could not resolve current monitor for positioning');
return;
}
const x = monitor.workArea.position.x + monitor.workArea.size.width - width - margin;
const y = monitor.workArea.position.y + monitor.workArea.size.height - height - margin;
await appWindow.setPosition(new LogicalPosition(x, y));
} catch (error) {
console.warn('[overlay] failed to pin overlay bottom-right after resize', error);
}
};
void updateWindowFrame();
}, [status]);
const bubbles = useMemo<OverlayBubble[]>(() => {
if (scenario === 1) {
return [];
@@ -124,11 +166,17 @@ export default function OverlayApp() {
return 'border-slate-950 bg-slate-800';
}, [status]);
const tetrahedronInverted = status === 'active';
const orbSizeClassName = status === 'active' ? 'h-[52px] w-[52px]' : 'h-[40px] w-[40px]';
const orbCanvasClassName = status === 'active' ? 'h-[92%] w-[92%]' : 'h-[88%] w-[88%]';
const orbStyle =
status === 'idle' ? { opacity: isHovered ? 1 : OVERLAY_IDLE_OPACITY } : undefined;
return (
<div className="flex h-screen w-screen items-end justify-end bg-transparent px-0 py-0">
<div className="relative flex select-none flex-col items-end gap-3">
<div className="flex max-w-[190px] flex-col items-end gap-2">
<div
className={`relative flex select-none flex-col items-end ${status === 'active' ? 'gap-3' : 'gap-0'}`}>
<div
className={`flex flex-col items-end gap-2 transition-all duration-200 ${status === 'active' ? 'max-w-[184px] opacity-100' : 'max-w-0 opacity-0'}`}>
{bubbles.map(bubble => (
<div key={bubble.id} className="animate-[overlay-bubble-in_220ms_ease-out]">
<OverlayBubbleChip bubble={bubble} />
@@ -143,9 +191,17 @@ export default function OverlayApp() {
onClick={() => {
setScenario(2);
}}
className={`group relative flex h-[56px] w-[56px] cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-all duration-200 ${orbClassName}`}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
className={`group relative flex cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-all duration-200 ${orbClassName} ${orbSizeClassName}`}
style={orbStyle}
title="Click to start the demo.">
<div className="pointer-events-none h-[92%] w-[92%] opacity-95 transition-transform duration-300 group-hover:scale-105">
<div
className={`pointer-events-none opacity-95 transition-transform duration-300 group-hover:scale-105 ${orbCanvasClassName}`}>
<RotatingTetrahedronCanvas inverted={tetrahedronInverted} />
</div>
</button>
+4 -4
View File
@@ -1218,7 +1218,7 @@ const Conversations = () => {
</div>
)}
{teamUsage &&
(teamUsage.remainingUsd <= 0 ||
((teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0) ||
(!teamUsage.bypassCycleLimit &&
teamUsage.fiveHourCapUsd > 0 &&
teamUsage.cycleLimit5hr >= teamUsage.fiveHourCapUsd)) && (
@@ -1237,12 +1237,12 @@ const Conversations = () => {
/>
</svg>
<p className="text-xs text-coral-600 truncate">
{teamUsage.remainingUsd <= 0
? 'Weekly inference budget exhausted. Top up to continue.'
{teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0
? 'Included weekly inference budget exhausted. Top up to continue.'
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
</p>
</div>
{teamUsage.remainingUsd <= 0 && (
{teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0 && (
<button
onClick={() => navigate('/settings/billing')}
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
+1 -2
View File
@@ -5,8 +5,7 @@ import ReferralRewardsSection from '../components/referral/ReferralRewardsSectio
import RewardsCouponSection from '../components/rewards/RewardsCouponSection';
import { useUser } from '../hooks/useUser';
import { useAppSelector } from '../store/hooks';
const DISCORD_INVITE_URL = 'https://discord.com/invite/k23Kn8nK';
import { DISCORD_INVITE_URL } from '../utils/links';
interface RewardRole {
id: string;
@@ -7,6 +7,7 @@ vi.mock('../../coreCommandClient', () => ({
}));
const { billingApi } = await import('../billingApi');
const { creditsApi } = await import('../creditsApi');
describe('billingApi', () => {
beforeEach(() => {
@@ -43,9 +44,9 @@ describe('billingApi', () => {
hasActiveSubscription: false,
planExpiry: null,
subscription: null,
monthlyBudgetUsd: 1,
weeklyBudgetUsd: 0.5,
fiveHourCapUsd: 0.15,
monthlyBudgetUsd: 0,
weeklyBudgetUsd: 0,
fiveHourCapUsd: 0,
};
mockCallCoreCommand.mockResolvedValue(planData);
@@ -54,7 +55,7 @@ describe('billingApi', () => {
expect(result.plan).toBe('FREE');
expect(result.hasActiveSubscription).toBe(false);
expect(result.subscription).toBeNull();
expect(result.weeklyBudgetUsd).toBe(0.5);
expect(result.weeklyBudgetUsd).toBe(0);
});
it('should propagate errors', async () => {
@@ -199,3 +200,18 @@ describe('billingApi', () => {
});
});
});
describe('creditsApi.getBalance', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('normalizes missing numeric fields so billing UI does not crash', async () => {
mockCallCoreCommand.mockResolvedValue({ topUpBalanceUsd: 3 });
const result = await creditsApi.getBalance();
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_balance');
expect(result).toEqual({ balanceUsd: 0, topUpBalanceUsd: 3, topUpBaselineUsd: null });
});
});
+21 -1
View File
@@ -181,6 +181,25 @@ export function normalizeRedeemedCoupon(raw: unknown): RedeemedCoupon {
};
}
function normalizeUsd(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function normalizeNullableUsd(value: unknown): number | null {
if (value == null) return null;
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function normalizeCreditBalance(payload: unknown): CreditBalance {
const raw = payload && typeof payload === 'object' ? (payload as Partial<CreditBalance>) : {};
return {
balanceUsd: normalizeUsd(raw.balanceUsd),
topUpBalanceUsd: normalizeUsd(raw.topUpBalanceUsd),
topUpBaselineUsd: normalizeNullableUsd(raw.topUpBaselineUsd),
};
}
/**
* Credits API endpoints
*/
@@ -190,7 +209,8 @@ export const creditsApi = {
* GET /credits/balance
*/
getBalance: async (): Promise<CreditBalance> => {
return await callCoreCommand<CreditBalance>('openhuman.billing_get_balance');
const result = await callCoreCommand<CreditBalance>('openhuman.billing_get_balance');
return normalizeCreditBalance(result);
},
/**
+1
View File
@@ -0,0 +1 @@
export const DISCORD_INVITE_URL = 'https://discord.tinyhumans.ai';
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}
-7
View File
@@ -1,7 +0,0 @@
# Tauri + Vanilla TS
This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenHuman Overlay</title>
</head>
<body class="bg-transparent">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
{
"name": "openhuman-overlay",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-7
View File
@@ -1,7 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
-9699
View File
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,29 +0,0 @@
[package]
name = "openhuman-overlay"
version = "0.1.0"
description = "OpenHuman transparent overlay with debug log viewer"
authors = ["you"]
edition = "2021"
[lib]
name = "overlay_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
log = "0.4"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tracing-log = "0.2"
chrono = "0.4"
parking_lot = "0.12"
# Core library — runs in-process (package name is "openhuman", lib name is "openhuman_core")
openhuman = { path = "../../" }
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
</dict>
</plist>
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
@@ -1,23 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the overlay window",
"windows": ["overlay"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-set-always-on-top",
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-close",
"core:window:allow-minimize",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit",
"opener:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-171
View File
@@ -1,171 +0,0 @@
mod log_bridge;
use log_bridge::{LogBuffer, LogEntry, TauriLogLayer};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(target_os = "macos")]
use tauri::ActivationPolicy;
use tauri::Manager;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
/// Tauri state holding the log ring buffer and click-through toggle.
struct OverlayState {
log_buffer: Arc<LogBuffer>,
click_through: Arc<AtomicBool>,
}
// ── Tauri commands ──────────────────────────────────────────────────────────
/// Return all buffered log entries (for initial load / reconnect).
#[tauri::command]
fn get_log_history(state: tauri::State<'_, OverlayState>) -> Vec<LogEntry> {
state.log_buffer.snapshot()
}
/// Toggle click-through mode. When enabled, mouse events pass through
/// the overlay to the window underneath.
#[tauri::command]
fn set_click_through(
window: tauri::WebviewWindow,
state: tauri::State<'_, OverlayState>,
enabled: bool,
) -> Result<(), String> {
state.click_through.store(enabled, Ordering::Relaxed);
window
.set_ignore_cursor_events(enabled)
.map_err(|e| e.to_string())?;
log::debug!("[overlay] click-through set to {}", enabled);
Ok(())
}
/// JSON-RPC URL of the desktop core sidecar, when the overlay was spawned by it.
/// When set, the web UI should prefer HTTP `fetch` to this URL so autocomplete,
/// screen intelligence, and voice state match the main app (see `overlay/src/parentCoreRpc.ts`).
#[tauri::command]
fn overlay_parent_rpc_url() -> Option<String> {
let url = std::env::var("OPENHUMAN_OVERLAY_PARENT_RPC_URL").ok()?;
let trimmed = url.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
}
/// Forward an RPC call to openhuman_core's dispatch in-process.
/// Uses the same invoke_method path as the HTTP JSON-RPC server.
#[tauri::command]
async fn core_rpc(method: String, params: serde_json::Value) -> Result<serde_json::Value, String> {
log::debug!("[overlay] core_rpc: method={}", method);
let state = openhuman_core::core::jsonrpc::default_state();
openhuman_core::core::jsonrpc::invoke_method(state, &method, params).await
}
/// Insert text into the currently focused field in the previously active app.
#[tauri::command]
fn insert_text_into_focused_field(text: String) -> Result<(), String> {
log::debug!(
"[overlay] insert_text_into_focused_field len={}",
text.chars().count()
);
openhuman_core::openhuman::accessibility::apply_text_to_focused_field(&text)
}
// ── App entry ───────────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Shared state
let log_buffer = Arc::new(LogBuffer::new(5000));
let click_through = Arc::new(AtomicBool::new(false));
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(OverlayState {
log_buffer: log_buffer.clone(),
click_through: click_through.clone(),
})
.invoke_handler(tauri::generate_handler![
get_log_history,
set_click_through,
overlay_parent_rpc_url,
core_rpc,
insert_text_into_focused_field,
])
.setup(move |app| {
let app_handle = app.handle().clone();
#[cfg(target_os = "macos")]
{
app.set_activation_policy(ActivationPolicy::Accessory);
log::debug!("[overlay] macOS: activation policy set to accessory");
}
// ── Tracing subscriber with Tauri bridge layer ──────────────
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"debug,hyper=info,reqwest=info,tungstenite=info,tokio_tungstenite=info",
)
});
let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_ansi(true);
let tauri_layer = TauriLogLayer::new(app_handle.clone(), log_buffer.clone());
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.with(tauri_layer)
.init();
// Bridge `log` crate macros into tracing
tracing_log::LogTracer::init().ok();
log::info!("[overlay] overlay process started, tracing bridge active");
// ── Optional in-process JSON-RPC (standalone / dev without a parent core) ──
// When spawned by the desktop sidecar, OPENHUMAN_OVERLAY_PARENT_RPC_URL is set and
// the web UI talks to the parent over HTTP — do not bind a second server on 7788.
// Use OPENHUMAN_OVERLAY_EMBEDDED_CORE_PORT (default 7799), not OPENHUMAN_CORE_PORT.
let parent_rpc = std::env::var("OPENHUMAN_OVERLAY_PARENT_RPC_URL")
.ok()
.filter(|s| !s.trim().is_empty());
if parent_rpc.is_some() {
log::info!(
"[overlay] parent core RPC URL set — skipping embedded JSON-RPC server"
);
} else {
tauri::async_runtime::spawn(async move {
let port = std::env::var("OPENHUMAN_OVERLAY_EMBEDDED_CORE_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(7799);
log::info!(
"[overlay] starting embedded openhuman_core server on 127.0.0.1:{} (standalone)",
port
);
match openhuman_core::core::jsonrpc::run_server_embedded(None, Some(port), true).await {
Ok(()) => log::info!("[overlay] embedded core server shut down cleanly"),
Err(e) => log::error!("[overlay] embedded core server error: {}", e),
}
});
}
// ── macOS: floating panel + visible on all workspaces ───────
#[cfg(target_os = "macos")]
{
if let Some(window) = app.get_webview_window("overlay") {
window.set_always_on_top(true).ok();
window.set_visible_on_all_workspaces(true).ok();
log::debug!("[overlay] macOS: set always-on-top + visible-on-all-workspaces");
}
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running overlay");
}
-146
View File
@@ -1,146 +0,0 @@
//! Captures `tracing` logs from openhuman_core and forwards them as Tauri events.
//!
//! Each log entry is emitted as a `core:log` event with a JSON payload:
//! ```json
//! { "ts": "2026-04-04T12:00:00Z", "level": "DEBUG", "module": "skills", "message": "..." }
//! ```
//! The frontend can filter by module to show logs from specific subsystems
//! (skills, screen_recorder, autocomplete, rpc, etc.).
use chrono::Utc;
use parking_lot::Mutex;
use serde::Serialize;
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tracing::field::{Field, Visit};
use tracing::span;
use tracing_subscriber::layer::Context;
use tracing_subscriber::Layer;
/// A single log entry forwarded to the overlay frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LogEntry {
pub ts: String,
pub level: String,
pub module: String,
pub target: String,
pub message: String,
}
/// Ring buffer that keeps the last N log entries so the frontend can fetch
/// history on connect without missing early startup logs.
pub struct LogBuffer {
entries: Mutex<Vec<LogEntry>>,
capacity: usize,
}
impl LogBuffer {
pub fn new(capacity: usize) -> Self {
Self {
entries: Mutex::new(Vec::with_capacity(capacity)),
capacity,
}
}
pub fn push(&self, entry: LogEntry) {
let mut entries = self.entries.lock();
if entries.len() >= self.capacity {
entries.remove(0);
}
entries.push(entry);
}
pub fn snapshot(&self) -> Vec<LogEntry> {
self.entries.lock().clone()
}
}
/// tracing Layer that captures events and sends them to the Tauri frontend.
pub struct TauriLogLayer {
app: AppHandle,
buffer: Arc<LogBuffer>,
}
impl TauriLogLayer {
pub fn new(app: AppHandle, buffer: Arc<LogBuffer>) -> Self {
Self { app, buffer }
}
}
/// Visitor that extracts the `message` field from tracing events.
struct MessageVisitor {
message: String,
}
impl MessageVisitor {
fn new() -> Self {
Self {
message: String::new(),
}
}
}
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
} else if self.message.is_empty() {
// Fall back to first field if no explicit "message"
self.message = format!("{}: {:?}", field.name(), value);
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.message = value.to_string();
}
}
}
/// Derive a human-friendly module name from the tracing target.
/// e.g. "openhuman::skills::qjs_engine" -> "skills"
/// "openhuman::rpc" -> "rpc"
/// "core_server::dispatch" -> "core_server"
fn module_from_target(target: &str) -> String {
let parts: Vec<&str> = target.split("::").collect();
// Try to find the second segment under "openhuman::"
if parts.len() >= 2 && parts[0] == "openhuman" {
return parts[1].to_string();
}
if parts.len() >= 2 && parts[0] == "openhuman_core" {
return parts[1].to_string();
}
// For other crates, use the first segment
parts.first().unwrap_or(&"unknown").to_string()
}
impl<S> Layer<S> for TauriLogLayer
where
S: tracing::Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let metadata = event.metadata();
let level = metadata.level().to_string();
let target = metadata.target().to_string();
let module = module_from_target(&target);
let mut visitor = MessageVisitor::new();
event.record(&mut visitor);
let entry = LogEntry {
ts: Utc::now().to_rfc3339(),
level,
module,
target,
message: visitor.message,
};
// Buffer for late-joining frontends
self.buffer.push(entry.clone());
// Emit to all listening webviews — fire-and-forget
let _ = self.app.emit("core:log", &entry);
}
fn on_new_span(&self, _attrs: &span::Attributes<'_>, _id: &span::Id, _ctx: Context<'_, S>) {}
}
-6
View File
@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
overlay_lib::run()
}
-49
View File
@@ -1,49 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "openhuman-overlay",
"version": "0.1.0",
"identifier": "com.openhuman.overlay",
"build": {
"beforeDevCommand": "yarn dev",
"devUrl": "http://localhost:1430",
"beforeBuildCommand": "yarn build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "overlay",
"title": "",
"width": 376,
"height": 520,
"minWidth": 376,
"minHeight": 432,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": false,
"visible": false,
"center": false,
"x": 16,
"y": 16
}
],
"macOSPrivateApi": true,
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
-882
View File
@@ -1,882 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { callParentCoreRpc } from "./parentCoreRpc";
const TARGET_SAMPLE_RATE = 16000;
type OverlayStatus = "idle" | "listening" | "transcribing" | "ready" | "error";
interface TranscribeResult {
text: string;
raw_text: string;
model_id: string;
}
interface GlobeHotkeyStatus {
supported: boolean;
running: boolean;
input_monitoring_permission: string;
last_error: string | null;
events_pending: number;
}
interface GlobeHotkeyPollResult {
status: GlobeHotkeyStatus;
events: string[];
}
interface AppContextInfo {
app_name: string | null;
window_title: string | null;
}
interface AccessibilitySessionStatus {
active: boolean;
capture_count: number;
frames_in_memory: number;
last_capture_at_ms: number | null;
last_context: string | null;
last_window_title: string | null;
vision_enabled: boolean;
vision_state: string;
vision_queue_depth: number;
}
interface AccessibilityStatus {
is_context_blocked: boolean;
foreground_context: AppContextInfo | null;
session: AccessibilitySessionStatus;
}
interface AutocompleteSuggestion {
value: string;
confidence: number;
}
interface AutocompleteStatus {
platform_supported: boolean;
enabled: boolean;
running: boolean;
phase: string;
app_name: string | null;
last_error: string | null;
updated_at_ms: number | null;
suggestion: AutocompleteSuggestion | null;
}
/** Matches `VoiceStatus` in src/openhuman/voice/types.rs */
interface VoiceStatus {
stt_available: boolean;
tts_available: boolean;
stt_model_id: string;
tts_voice_id: string;
whisper_binary: string | null;
piper_binary: string | null;
stt_model_path: string | null;
tts_voice_path: string | null;
whisper_in_process: boolean;
llm_cleanup_enabled: boolean;
}
interface OverlayDebugSnapshot {
screen: AccessibilityStatus | null;
autocomplete: AutocompleteStatus | null;
voice: VoiceStatus | null;
updatedAt: number | null;
error: string | null;
}
const DEBUG_EXPANDED_KEY = "openhuman_overlay_debug_expanded";
function logOverlay(message: string, details?: unknown) {
if (details) {
console.debug(`[overlay] ${message}`, details);
return;
}
console.debug(`[overlay] ${message}`);
}
function formatTimestamp(timestampMs: number | null): string {
if (!timestampMs) {
return "none";
}
try {
return new Date(timestampMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return String(timestampMs);
}
}
function floatTo16BitPCM(output: DataView, offset: number, input: Float32Array) {
for (let i = 0; i < input.length; i += 1, offset += 2) {
const sample = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
}
}
function encodeWavMono16k(samples: Float32Array, sampleRate: number): Uint8Array {
const bytesPerSample = 2;
const blockAlign = bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
const writeString = (offset: number, value: string) => {
for (let i = 0; i < value.length; i += 1) {
view.setUint8(offset + i, value.charCodeAt(i));
}
};
writeString(0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeString(8, "WAVE");
writeString(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true);
writeString(36, "data");
view.setUint32(40, dataSize, true);
floatTo16BitPCM(view, 44, samples);
return new Uint8Array(buffer);
}
async function toMono16k(audioBuffer: AudioBuffer): Promise<Float32Array> {
const channels = audioBuffer.numberOfChannels;
const mono = new Float32Array(audioBuffer.length);
for (let c = 0; c < channels; c += 1) {
const channelData = audioBuffer.getChannelData(c);
for (let i = 0; i < audioBuffer.length; i += 1) {
mono[i] += channelData[i] / channels;
}
}
if (audioBuffer.sampleRate === TARGET_SAMPLE_RATE) {
return mono;
}
const targetLength = Math.max(
1,
Math.round((mono.length * TARGET_SAMPLE_RATE) / audioBuffer.sampleRate),
);
const offline = new OfflineAudioContext(1, targetLength, TARGET_SAMPLE_RATE);
const sourceBuffer = offline.createBuffer(1, mono.length, audioBuffer.sampleRate);
sourceBuffer.copyToChannel(mono, 0);
const source = offline.createBufferSource();
source.buffer = sourceBuffer;
source.connect(offline.destination);
source.start();
const rendered = await offline.startRendering();
return rendered.getChannelData(0).slice();
}
async function convertBlobToWavBytes(blob: Blob): Promise<number[]> {
const arrayBuffer = await blob.arrayBuffer();
const audioContext = new AudioContext();
try {
const decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0));
const mono16k = await toMono16k(decoded);
return Array.from(encodeWavMono16k(mono16k, TARGET_SAMPLE_RATE));
} finally {
await audioContext.close();
}
}
function MicrophoneIcon({ active }: { active: boolean }) {
return (
<svg
aria-hidden="true"
className={`h-9 w-9 transition-transform duration-200 ${active ? "scale-105" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M6 11a6 6 0 0 0 12 0" />
<path d="M12 17v4" />
<path d="M8.5 21h7" />
</svg>
);
}
export function App() {
const appWindow = getCurrentWindow();
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const sessionIdRef = useRef(0);
const globePollInFlightRef = useRef(false);
/** `undefined` until Tauri reports env; then URL string or `null` (embedded core only). */
const [parentRpcUrl, setParentRpcUrl] = useState<string | null | undefined>(undefined);
const [coreReachable, setCoreReachable] = useState(true);
const [voiceCaptureEnabled, setVoiceCaptureEnabled] = useState(true);
const [debugExpanded, setDebugExpanded] = useState(() => {
try {
return typeof localStorage !== "undefined" && localStorage.getItem(DEBUG_EXPANDED_KEY) === "1";
} catch {
return false;
}
});
const [status, setStatus] = useState<OverlayStatus>("idle");
const [message, setMessage] = useState("Click to start listening");
const [transcript, setTranscript] = useState("");
const [debugSnapshot, setDebugSnapshot] = useState<OverlayDebugSnapshot>({
screen: null,
autocomplete: null,
voice: null,
updatedAt: null,
error: null,
});
useEffect(() => {
let mounted = true;
void invoke<string | null>("overlay_parent_rpc_url")
.then((url) => {
if (!mounted) return;
const trimmed = url?.trim();
setParentRpcUrl(trimmed && trimmed.length > 0 ? trimmed : null);
})
.catch(() => {
if (mounted) setParentRpcUrl(null);
});
return () => {
mounted = false;
};
}, []);
const rpc = useCallback(
async <T,>(method: string, params: Record<string, unknown> = {}): Promise<T> => {
if (parentRpcUrl === undefined) {
throw new Error("[overlay] RPC not initialized");
}
if (parentRpcUrl) {
return callParentCoreRpc<T>(parentRpcUrl, method, params);
}
return invoke<T>("core_rpc", { method, params });
},
[parentRpcUrl],
);
const persistDebugExpanded = useCallback((expanded: boolean) => {
setDebugExpanded(expanded);
try {
localStorage.setItem(DEBUG_EXPANDED_KEY, expanded ? "1" : "0");
} catch {
/* ignore */
}
}, []);
useEffect(() => {
let disposed = false;
const showOverlayFallback = async (message: string) => {
if (disposed) {
return;
}
logOverlay("globe listener unavailable", { message });
setMessage(message);
await appWindow.show().catch(() => {});
};
const startGlobeListener = async () => {
if (parentRpcUrl === undefined) {
return;
}
try {
const result = await rpc<GlobeHotkeyStatus>("openhuman.screen_intelligence_globe_listener_start", {});
logOverlay("globe listener start result", result);
if (!result.supported) {
await showOverlayFallback("Globe/Fn hotkey is only supported on macOS");
return;
}
if (!result.running) {
await showOverlayFallback(
result.last_error ?? "Globe/Fn listener could not start. Check Input Monitoring.",
);
}
} catch (error) {
console.error("[overlay] failed to start globe listener", error);
await showOverlayFallback("Failed to start Globe/Fn listener");
}
};
const pollGlobeListener = async () => {
if (disposed || parentRpcUrl === undefined || globePollInFlightRef.current) {
return;
}
globePollInFlightRef.current = true;
try {
const result = await rpc<GlobeHotkeyPollResult>("openhuman.screen_intelligence_globe_listener_poll", {});
if (disposed) {
return;
}
if (!result.status.running && result.status.last_error) {
setMessage(result.status.last_error);
}
if (result.events.includes("FN_UP")) {
const visible = await appWindow.isVisible();
logOverlay("received FN_UP", { visible });
if (visible) {
await appWindow.hide();
} else {
await appWindow.show();
}
}
} catch (error) {
if (!disposed) {
console.warn("[overlay] globe listener poll failed", error);
}
} finally {
globePollInFlightRef.current = false;
}
};
void startGlobeListener();
const intervalId = window.setInterval(() => {
void pollGlobeListener();
}, 175);
return () => {
disposed = true;
window.clearInterval(intervalId);
if (parentRpcUrl === undefined) {
return;
}
void rpc("openhuman.screen_intelligence_globe_listener_stop", {}).catch(() => {});
};
}, [appWindow, parentRpcUrl, rpc]);
useEffect(() => {
let disposed = false;
let pollInFlight = false;
const pollDebugState = async () => {
if (disposed || parentRpcUrl === undefined || pollInFlight) {
return;
}
pollInFlight = true;
try {
if (parentRpcUrl) {
try {
await rpc<{ ok?: boolean }>("core.ping", {});
if (!disposed) {
setCoreReachable(true);
}
} catch {
if (!disposed) {
setCoreReachable(false);
}
}
} else {
setCoreReachable(true);
}
const [screen, autocomplete, voice] = await Promise.all([
rpc<AccessibilityStatus>("openhuman.screen_intelligence_status", {}),
rpc<AutocompleteStatus>("openhuman.autocomplete_status", {}),
rpc<VoiceStatus>("openhuman.voice_status", {}),
]);
if (disposed) {
return;
}
logOverlay("debug snapshot refreshed", {
screenActive: screen.session.active,
captureCount: screen.session.capture_count,
autocompletePhase: autocomplete.phase,
hasSuggestion: Boolean(autocomplete.suggestion?.value),
sttAvailable: voice.stt_available,
});
setDebugSnapshot({
screen,
autocomplete,
voice,
updatedAt: Date.now(),
error: null,
});
} catch (error) {
if (disposed) {
return;
}
const nextError =
error instanceof Error ? error.message : "Failed to refresh overlay debug state";
console.warn("[overlay] debug snapshot poll failed", error);
setDebugSnapshot((previous) => ({
...previous,
updatedAt: Date.now(),
error: nextError,
}));
} finally {
pollInFlight = false;
}
};
void pollDebugState();
const intervalId = window.setInterval(() => {
void pollDebugState();
}, 900);
return () => {
disposed = true;
window.clearInterval(intervalId);
};
}, [parentRpcUrl, rpc]);
const insertTranscriptIntoFocusedField = useCallback(
async (text: string) => {
logOverlay("inserting transcript into focused field", { length: text.length });
await appWindow.hide();
await new Promise((resolve) => window.setTimeout(resolve, 120));
try {
await invoke("insert_text_into_focused_field", { text });
logOverlay("transcript inserted via accessibility helper");
} catch (error) {
console.warn("[overlay] accessibility insert failed, falling back to clipboard", error);
await navigator.clipboard.writeText(text);
}
},
[appWindow],
);
const resetForNextCapture = useCallback(() => {
setTranscript("");
setMessage("Click to start listening");
setStatus("idle");
}, []);
const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, []);
const transcribeBlob = useCallback(
async (blob: Blob, sessionId: number) => {
try {
const audioBytes = await convertBlobToWavBytes(blob);
const result = await rpc<TranscribeResult>("openhuman.voice_transcribe_bytes", {
audio_bytes: audioBytes,
extension: "wav",
skip_cleanup: false,
});
if (sessionIdRef.current !== sessionId) {
return;
}
const nextTranscript = result.text.trim();
if (!nextTranscript) {
setTranscript("");
setStatus("error");
setMessage("No speech detected");
return;
}
setTranscript(nextTranscript);
setStatus("ready");
setMessage("Inserting text...");
await insertTranscriptIntoFocusedField(nextTranscript);
if (sessionIdRef.current !== sessionId) {
return;
}
setMessage("Inserted into active field");
} catch (error) {
if (sessionIdRef.current !== sessionId) {
return;
}
console.error("[overlay] transcription failed", error);
setTranscript("");
setStatus("error");
setMessage(error instanceof Error ? error.message : "Transcription failed");
}
},
[insertTranscriptIntoFocusedField, rpc],
);
const stopRecording = useCallback(() => {
if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
return;
}
setStatus("transcribing");
setMessage("Transcribing...");
mediaRecorderRef.current.stop();
mediaRecorderRef.current = null;
}, []);
const startRecording = useCallback(async () => {
const nextSessionId = sessionIdRef.current + 1;
sessionIdRef.current = nextSessionId;
setTranscript("");
setStatus("listening");
setMessage("Listening...");
chunksRef.current = [];
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm"
: "audio/ogg";
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onerror = (event) => {
console.error("[overlay] media recorder error", event);
cleanupStream();
setStatus("error");
setMessage("Microphone recording failed");
};
recorder.onstop = () => {
cleanupStream();
const blob = new Blob(chunksRef.current, { type: mimeType });
chunksRef.current = [];
if (blob.size === 0) {
setStatus("error");
setMessage("No audio recorded");
return;
}
logOverlay("recording stopped, starting transcription", {
blobSize: blob.size,
mimeType,
});
void transcribeBlob(blob, nextSessionId);
};
logOverlay("recording started", { mimeType });
recorder.start(100);
} catch (error) {
console.error("[overlay] getUserMedia failed", error);
cleanupStream();
setStatus("error");
setMessage(error instanceof Error ? error.message : "Microphone access failed");
}
}, [cleanupStream, transcribeBlob]);
const handleMainButton = useCallback(() => {
// Always allow stopping an active recording, regardless of config state.
if (status === "listening") {
logOverlay("main button toggled to stop listening");
stopRecording();
return;
}
if (!voiceCaptureEnabled) {
setMessage("Turn on voice capture to use the microphone");
return;
}
if (debugSnapshot.voice && !debugSnapshot.voice.stt_available) {
setMessage(
"Speech-to-text is not available. Configure Local AI / voice in the main OpenHuman app.",
);
return;
}
logOverlay("main button toggled to start listening", { priorStatus: status });
void startRecording();
}, [
debugSnapshot.voice,
startRecording,
status,
stopRecording,
voiceCaptureEnabled,
]);
const shellClassName = useMemo(() => {
if (status === "listening") {
return "from-red-500/90 via-rose-500/80 to-orange-400/85 text-white shadow-[0_0_64px_rgba(248,113,113,0.38)]";
}
if (status === "transcribing") {
return "from-amber-400/90 via-orange-400/80 to-yellow-300/80 text-stone-950 shadow-[0_0_56px_rgba(251,191,36,0.34)]";
}
if (status === "error") {
return "from-red-600/90 via-rose-700/80 to-stone-900/90 text-white shadow-[0_0_56px_rgba(190,24,93,0.35)]";
}
if (status === "ready") {
return "from-emerald-400/90 via-teal-400/80 to-cyan-300/80 text-stone-950 shadow-[0_0_56px_rgba(45,212,191,0.34)]";
}
return "from-slate-900/92 via-slate-800/92 to-slate-700/92 text-white shadow-[0_0_48px_rgba(15,23,42,0.42)]";
}, [status]);
const activeScreenApp =
debugSnapshot.screen?.foreground_context?.app_name ??
debugSnapshot.screen?.session.last_context ??
"Unknown app";
const activeScreenWindow =
debugSnapshot.screen?.foreground_context?.window_title ??
debugSnapshot.screen?.session.last_window_title ??
"No active window title";
const autocompleteSuggestion = debugSnapshot.autocomplete?.suggestion?.value?.trim() ?? "";
const autocompletePhase = debugSnapshot.autocomplete?.phase ?? "unknown";
const autocompleteRunning =
debugSnapshot.autocomplete?.running && debugSnapshot.autocomplete?.enabled;
const sttAvailable = debugSnapshot.voice?.stt_available ?? true;
const voiceBlocked =
!voiceCaptureEnabled || (debugSnapshot.voice !== null && !debugSnapshot.voice.stt_available);
const waitingForCoreConfig = parentRpcUrl === undefined;
return (
<div className="flex h-screen w-screen items-start justify-start bg-transparent p-3">
<div className="relative select-none">
{status === "listening" ? (
<>
<span className="pointer-events-none absolute inset-0 rounded-full border border-white/15 animate-ping" />
<span className="pointer-events-none absolute -inset-3 rounded-full border border-red-300/30 blur-[2px]" />
</>
) : null}
<div
className={`relative w-[348px] rounded-[32px] border border-white/15 bg-gradient-to-br p-3 backdrop-blur-xl transition-all duration-200 ${shellClassName}`}
onMouseDown={(event) => {
if (event.target instanceof HTMLElement && event.target.closest("button")) {
return;
}
void appWindow.startDragging();
}}
>
{parentRpcUrl && !coreReachable ? (
<div className="mb-2 rounded-2xl border border-amber-400/35 bg-amber-950/35 px-3 py-2 text-[11px] leading-4 text-amber-50">
Cannot reach the OpenHuman core at the sidecar URL. Autocomplete and screen debug may
be stale. Check that the main app is running.
</div>
) : null}
<div className="mb-3 flex items-center justify-between gap-2">
<span className="rounded-full bg-black/15 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.24em]">
Voice
</span>
<button
type="button"
className="h-7 w-7 rounded-full border border-white/15 bg-black/20 text-sm transition hover:bg-black/30"
onClick={() => appWindow.hide()}
aria-label="Hide overlay"
>
×
</button>
</div>
<div className="mb-3 flex items-center justify-between gap-2 rounded-2xl border border-white/10 bg-black/15 px-3 py-2">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-80">
Voice capture
</p>
<p className="mt-0.5 text-[11px] leading-4 opacity-85">
{waitingForCoreConfig
? "Checking core…"
: sttAvailable
? debugSnapshot.voice?.whisper_in_process
? "STT ready (in-process)"
: "STT ready"
: "STT unavailable — configure voice in the main app"}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={voiceCaptureEnabled}
aria-label={voiceCaptureEnabled ? "Turn voice capture off" : "Turn voice capture on"}
className={`relative h-8 w-[52px] shrink-0 rounded-full border border-white/15 transition ${
voiceCaptureEnabled ? "bg-emerald-500/50" : "bg-black/35"
}`}
onClick={() => setVoiceCaptureEnabled((previous) => !previous)}
>
<span
className={`absolute top-1 h-6 w-6 rounded-full bg-white shadow transition ${
voiceCaptureEnabled ? "left-7" : "left-1"
}`}
/>
</button>
</div>
<div className="flex items-start gap-3">
<button
type="button"
onClick={handleMainButton}
disabled={waitingForCoreConfig || voiceBlocked}
className={`group relative flex h-[108px] w-[108px] shrink-0 items-center justify-center rounded-full border border-white/20 bg-black/20 transition duration-200 hover:bg-black/28 disabled:cursor-not-allowed disabled:opacity-40 ${
status === "listening" ? "scale-[1.02]" : ""
}`}
aria-label={status === "listening" ? "Stop listening" : "Start listening"}
>
<span className="absolute inset-3 rounded-full border border-white/12" />
<MicrophoneIcon active={status === "listening"} />
</button>
<div className="min-w-0 flex-1 pt-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">
{status}
</p>
<p className="mt-1 text-xs leading-4 opacity-90">{message}</p>
{transcript ? (
<div className="mt-3 rounded-2xl border border-white/10 bg-black/15 px-3 py-2 text-[11px] leading-4 opacity-95">
{transcript}
</div>
) : null}
{(status === "ready" || status === "error") && !transcript ? (
<button
type="button"
className="mt-3 w-full rounded-full border border-white/12 bg-black/15 px-3 py-2 text-[11px] font-medium transition hover:bg-black/25"
onClick={resetForNextCapture}
>
Reset
</button>
) : null}
</div>
</div>
<div className="mt-3 rounded-[24px] border border-white/10 bg-black/15 p-3">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex flex-1 items-center justify-between gap-2 text-left"
onClick={() => persistDebugExpanded(!debugExpanded)}
aria-expanded={debugExpanded}
>
<span className="text-[10px] font-semibold uppercase tracking-[0.22em] opacity-75">
Debug {debugExpanded ? "▼" : "▶"}
</span>
<span className="text-[10px] opacity-65">
{debugSnapshot.updatedAt ? formatTimestamp(debugSnapshot.updatedAt) : "waiting"}
</span>
</button>
</div>
{!debugExpanded ? (
<p className="mt-2 text-[11px] leading-4 opacity-80">
Screen: {debugSnapshot.screen?.session.active ? "session on" : "idle"} ·
Autocomplete: {autocompletePhase}
{debugSnapshot.autocomplete?.last_error ? " · error" : ""}
</p>
) : null}
{debugSnapshot.error ? (
<div className="mt-3 rounded-2xl border border-red-300/20 bg-red-950/20 px-3 py-2 text-[11px] leading-4 text-red-100">
{debugSnapshot.error}
</div>
) : null}
{debugExpanded ? (
<div className="mt-3 grid gap-3">
<section className="rounded-2xl border border-white/8 bg-black/10 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">
Voice / STT
</div>
<div className="mt-2 space-y-1 text-[11px] leading-4 opacity-90">
<p>STT: {sttAvailable ? "available" : "unavailable"}</p>
<p className="truncate">Model: {debugSnapshot.voice?.stt_model_id ?? "—"}</p>
<p className="truncate">
Whisper: {debugSnapshot.voice?.whisper_binary ?? "not found"}
</p>
</div>
</section>
<section className="rounded-2xl border border-white/8 bg-black/10 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">
Screen Intelligence
</div>
<div className="mt-2 space-y-1 text-[11px] leading-4 opacity-90">
<p>Active screen: {activeScreenApp}</p>
<p className="truncate">Window: {activeScreenWindow}</p>
<p>
Screenshots: {debugSnapshot.screen?.session.capture_count ?? 0} total,{" "}
{debugSnapshot.screen?.session.frames_in_memory ?? 0} in memory
</p>
<p>
Session: {debugSnapshot.screen?.session.active ? "active" : "idle"} | Vision:{" "}
{debugSnapshot.screen?.session.vision_enabled ? "on" : "off"} /{" "}
{debugSnapshot.screen?.session.vision_state ?? "idle"}
</p>
<p>
Queue: {debugSnapshot.screen?.session.vision_queue_depth ?? 0} | Blocked:{" "}
{debugSnapshot.screen?.is_context_blocked ? "yes" : "no"}
</p>
<p>
Last capture:{" "}
{formatTimestamp(debugSnapshot.screen?.session.last_capture_at_ms ?? null)}
</p>
</div>
</section>
<section className="rounded-2xl border border-white/8 bg-black/10 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">
Autocomplete
</div>
<div className="mt-2 space-y-1 text-[11px] leading-4 opacity-90">
<p>
Status: {autocompleteRunning ? "active" : "idle"} | Phase: {autocompletePhase}
</p>
<p>App: {debugSnapshot.autocomplete?.app_name ?? activeScreenApp}</p>
<p>
Processing:{" "}
{autocompletePhase === "refreshing" || autocompletePhase === "processing"
? "yes"
: "no"}
</p>
<p>
Suggestions:{" "}
{autocompleteSuggestion ? "1 ready" : "none"}
</p>
<div className="rounded-xl border border-white/8 bg-black/10 px-2 py-2 text-[11px] leading-4">
{autocompleteSuggestion || "No autocomplete suggestion available."}
</div>
{debugSnapshot.autocomplete?.last_error ? (
<p className="text-red-100">
Error: {debugSnapshot.autocomplete.last_error}
</p>
) : null}
</div>
</section>
</div>
) : null}
</div>
</div>
</div>
</div>
);
}
-95
View File
@@ -1,95 +0,0 @@
import { useEffect, useMemo, useRef } from "react";
import type { LogEntry } from "../types";
import { LEVEL_COLORS } from "../types";
interface LogViewerProps {
entries: LogEntry[];
activeModule: string;
levelFilter: string;
}
/** Format ISO timestamp to HH:MM:SS.mmm */
function formatTime(ts: string): string {
try {
const d = new Date(ts);
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${h}:${m}:${s}.${ms}`;
} catch {
return ts.slice(11, 23);
}
}
const LEVEL_ORDER: Record<string, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
FATAL: 5,
};
/**
* Virtualized-ish log viewer. Auto-scrolls to bottom as new entries arrive.
* Filters by module and minimum log level.
*/
export function LogViewer({ entries, activeModule, levelFilter }: LogViewerProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isAtBottom = useRef(true);
const filtered = useMemo(() => {
const minLevel = LEVEL_ORDER[levelFilter] ?? 0;
return entries.filter((e) => {
if (activeModule !== "all" && e.module !== activeModule) return false;
const entryLevel = LEVEL_ORDER[e.level] ?? 0;
return entryLevel >= minLevel;
});
}, [entries, activeModule, levelFilter]);
// Track scroll position to decide auto-scroll
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const threshold = 40;
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
};
// Auto-scroll when new entries arrive (only if already at bottom)
useEffect(() => {
if (isAtBottom.current) {
bottomRef.current?.scrollIntoView({ behavior: "instant" });
}
}, [filtered.length]);
return (
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto log-scroll font-mono text-[11px] leading-[18px] px-2 py-1 bg-gray-950/90"
>
{filtered.length === 0 && (
<div className="text-white/20 text-center py-8 text-xs">
No logs yet...
</div>
)}
{filtered.map((entry, i) => (
<div key={i} className="flex gap-2 hover:bg-white/[0.03] px-1 rounded">
<span className="text-white/25 shrink-0">{formatTime(entry.ts)}</span>
<span className={`shrink-0 w-[42px] text-right ${LEVEL_COLORS[entry.level] ?? "text-white/40"}`}>
{entry.level}
</span>
<span className="text-purple-400/60 shrink-0 w-[80px] truncate" title={entry.target}>
{entry.module}
</span>
<span className="text-white/80 break-all">{entry.message}</span>
</div>
))}
<div ref={bottomRef} />
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
import { MODULE_LABELS } from "../types";
interface ModuleFilterProps {
modules: Set<string>;
activeModule: string;
onSelect: (module: string) => void;
}
/**
* Horizontal tab bar for filtering logs by module.
* Shows "All" plus every module that has emitted at least one log.
*/
export function ModuleFilter({ modules, activeModule, onSelect }: ModuleFilterProps) {
const tabs = ["all", ...Array.from(modules).sort()];
return (
<div className="flex items-center gap-1 px-2 py-1.5 bg-gray-900/60 border-b border-white/5 overflow-x-auto shrink-0">
{tabs.map((mod) => {
const isActive = mod === activeModule;
const label = MODULE_LABELS[mod] ?? mod;
return (
<button
key={mod}
onClick={() => onSelect(mod)}
className={`px-2 py-0.5 rounded text-[10px] font-mono whitespace-nowrap transition-colors ${
isActive
? "bg-primary-500/30 text-primary-500 border border-primary-500/40"
: "text-white/40 hover:text-white/60 hover:bg-white/5"
}`}
>
{label}
</button>
);
})}
</div>
);
}
-47
View File
@@ -1,47 +0,0 @@
interface StatusBarProps {
filteredInfo: string;
levelFilter: string;
onLevelChange: (level: string) => void;
onClear: () => void;
}
const LEVELS = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
/**
* Bottom status bar showing entry count, level filter, and clear button.
*/
export function StatusBar({
filteredInfo,
levelFilter,
onLevelChange,
onClear,
}: StatusBarProps) {
return (
<div className="flex items-center justify-between px-3 py-1 bg-gray-900/80 border-t border-white/5 shrink-0">
<div className="flex items-center gap-3">
<span className="text-[10px] text-white/30 font-mono">
{filteredInfo}
</span>
<select
value={levelFilter}
onChange={(e) => onLevelChange(e.target.value)}
className="text-[10px] bg-transparent text-white/50 border border-white/10 rounded px-1 py-0.5 cursor-pointer hover:border-white/20"
>
{LEVELS.map((l) => (
<option key={l} value={l} className="bg-gray-900 text-white">
{l}+
</option>
))}
</select>
</div>
<button
onClick={onClear}
className="text-[10px] text-white/30 hover:text-white/60 transition-colors"
>
Clear
</button>
</div>
);
}
-64
View File
@@ -1,64 +0,0 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
interface TitleBarProps {
clickThrough: boolean;
onClickThroughToggle: () => void;
}
/**
* Custom title bar for the frameless overlay window.
* Supports dragging, window controls, and click-through toggle.
*/
export function TitleBar({ clickThrough, onClickThroughToggle }: TitleBarProps) {
const appWindow = getCurrentWindow();
const handleClickThrough = async () => {
const next = !clickThrough;
try {
await invoke("set_click_through", { enabled: next });
onClickThroughToggle();
} catch (e) {
console.error("Failed to set click-through:", e);
}
};
return (
<div
data-tauri-drag-region
className="flex items-center justify-between h-8 px-3 bg-gray-900/80 backdrop-blur-md border-b border-white/5 cursor-move select-none shrink-0"
>
<span className="text-[11px] font-medium text-white/60 tracking-wide uppercase">
OpenHuman
</span>
<div className="flex items-center gap-2">
{/* Click-through toggle */}
<button
onClick={handleClickThrough}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
clickThrough
? "bg-blue-500/30 text-blue-400 border border-blue-500/40"
: "text-white/30 hover:text-white/50"
}`}
title={clickThrough ? "Click-through ON (clicks pass through)" : "Click-through OFF"}
>
{clickThrough ? "CT" : "CT"}
</button>
{/* Minimize */}
<button
onClick={() => appWindow.minimize()}
className="w-3 h-3 rounded-full bg-amber-500/80 hover:bg-amber-400 transition-colors"
title="Minimize"
/>
{/* Close */}
<button
onClick={() => appWindow.hide()}
className="w-3 h-3 rounded-full bg-red-500/80 hover:bg-red-400 transition-colors"
title="Hide"
/>
</div>
</div>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { LogEntry } from "../types";
const MAX_ENTRIES = 5000;
/**
* Subscribes to `core:log` Tauri events and manages the log buffer.
* On mount, fetches buffered history so we don't miss startup logs.
*/
export function useLogs() {
const [entries, setEntries] = useState<LogEntry[]>([]);
const [modules, setModules] = useState<Set<string>>(new Set());
const entriesRef = useRef<LogEntry[]>([]);
// Track seen modules for the filter UI
const modulesRef = useRef<Set<string>>(new Set());
const addEntries = useCallback((newEntries: LogEntry[]) => {
const current = entriesRef.current;
const updated = [...current, ...newEntries];
// Trim to max
if (updated.length > MAX_ENTRIES) {
updated.splice(0, updated.length - MAX_ENTRIES);
}
entriesRef.current = updated;
setEntries(updated);
// Track modules
let modulesChanged = false;
for (const e of newEntries) {
if (!modulesRef.current.has(e.module)) {
modulesRef.current.add(e.module);
modulesChanged = true;
}
}
if (modulesChanged) {
setModules(new Set(modulesRef.current));
}
}, []);
useEffect(() => {
// Fetch buffered history from Rust
invoke<LogEntry[]>("get_log_history")
.then((history) => {
if (history.length > 0) {
addEntries(history);
}
})
.catch(console.error);
// Subscribe to live log events
const unlisten = listen<LogEntry>("core:log", (event) => {
addEntries([event.payload]);
});
return () => {
unlisten.then((fn) => fn());
};
}, [addEntries]);
const clear = useCallback(() => {
entriesRef.current = [];
setEntries([]);
}, []);
return { entries, modules, clear };
}
-10
View File
@@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
-95
View File
@@ -1,95 +0,0 @@
/**
* HTTP JSON-RPC to the desktop core sidecar (same process as the main app).
* Mirrors the naming normalization in app/src/services/coreRpcClient.ts (subset).
*/
let nextJsonRpcId = 1;
export function normalizeLegacyMethod(method: string): string {
if (method.startsWith("openhuman.accessibility_")) {
return method.replace("openhuman.accessibility_", "openhuman.screen_intelligence_");
}
return method;
}
/** RpcOutcome with non-empty logs serializes as `{ result, logs }` in the core. */
function unwrapCliCompatibleJson<T>(raw: unknown): T {
if (
raw !== null &&
typeof raw === "object" &&
"result" in raw &&
"logs" in raw &&
Array.isArray((raw as { logs: unknown }).logs)
) {
return (raw as { result: T }).result;
}
return raw as T;
}
interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
interface JsonRpcResponse<T> {
jsonrpc?: string;
id?: number | string | null;
result?: T;
error?: JsonRpcError;
}
/** Default timeout for parent core RPC requests (ms). */
const DEFAULT_RPC_TIMEOUT_MS = 10_000;
export async function callParentCoreRpc<T>(
rpcUrl: string,
method: string,
params: Record<string, unknown> = {},
timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS,
): Promise<T> {
const normalizedMethod = normalizeLegacyMethod(method);
const payload = {
jsonrpc: "2.0" as const,
id: nextJsonRpcId++,
method: normalizedMethod,
params,
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
try {
response = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (err instanceof DOMException && err.name === "AbortError") {
throw new Error(`Core RPC request timed out after ${timeoutMs}ms (method: ${normalizedMethod})`);
}
throw err;
} finally {
clearTimeout(timer);
}
if (!response.ok) {
const text = await response.text();
throw new Error(`Core RPC HTTP ${response.status}: ${text || response.statusText}`);
}
const json = (await response.json()) as JsonRpcResponse<unknown>;
if (json.error) {
throw new Error(json.error.message || "Core RPC returned an error");
}
if (!Object.prototype.hasOwnProperty.call(json, "result")) {
throw new Error("Core RPC response missing result");
}
return unwrapCliCompatibleJson<T>(json.result);
}
-33
View File
@@ -1,33 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@300;400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
background: transparent;
overflow: hidden;
user-select: none;
}
body {
font-family: "Inter", system-ui, sans-serif;
}
/* Scrollbar styling for log viewer */
.log-scroll::-webkit-scrollbar {
width: 6px;
}
.log-scroll::-webkit-scrollbar-track {
background: transparent;
}
.log-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.log-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
-49
View File
@@ -1,49 +0,0 @@
/** A single log entry from the Rust core, received via `core:log` event. */
export interface LogEntry {
ts: string;
level: string;
module: string;
target: string;
message: string;
}
/** Known module names for filtering.
* As new apps/domains emit logs, they auto-appear in the filter bar.
* This map provides friendly labels for known modules. */
export const MODULE_LABELS: Record<string, string> = {
all: "All",
// ── Core domains ──
skills: "Skills",
rpc: "RPC",
core: "Core",
core_server: "Server",
config: "Config",
cron: "Cron",
memory: "Memory",
channels: "Channels",
overlay: "Overlay",
about_app: "About",
subconscious: "Subconscious",
// ── Apps / subsystems ──
screen_recorder: "Screen Rec",
autocomplete: "Autocomplete",
agent: "Agent",
search: "Search",
// ── Infra ──
axum: "HTTP",
tower_http: "HTTP",
socketioxide: "Socket.IO",
hyper: "Hyper",
reqwest: "Reqwest",
rusqlite: "SQLite",
};
/** Level colors for the log viewer. */
export const LEVEL_COLORS: Record<string, string> = {
TRACE: "text-gray-500",
DEBUG: "text-blue-400",
INFO: "text-green-400",
WARN: "text-amber-400",
ERROR: "text-red-400",
FATAL: "text-red-600",
};
-24
View File
@@ -1,24 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
mono: ["JetBrains Mono", "Menlo", "Monaco", "monospace"],
sans: ["Inter", "system-ui", "sans-serif"],
},
colors: {
canvas: {
50: "#FAFAF9",
100: "#F5F5F4",
200: "#E5E5E3",
},
primary: {
500: "#4A83DD",
600: "#3D6DC4",
},
},
},
},
plugins: [],
};
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
-25
View File
@@ -1,25 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: 1430,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1431,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));
-1284
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ pub struct AutocompleteConfig {
}
fn default_enabled() -> bool {
true
false
}
fn default_debounce_ms() -> u64 {