Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cd11bf2
Update Conversations component to enhance user messaging for budget l…
senamakel Apr 10, 2026
7e09c64
feat(schemas): add new configuration option for vision model usage
senamakel Apr 10, 2026
e596375
feat(CoreStateProvider): enhance state management with optimistic upd…
senamakel Apr 10, 2026
5a908ad
feat(paths): centralize runtime path resolution for user-scoped skill…
senamakel Apr 10, 2026
e6a26d1
feat(ScreenIntelligencePanel): optimize config synchronization to pre…
senamakel Apr 10, 2026
61d8b57
feat(SkillManager): implement initial sync after OAuth completion
senamakel Apr 10, 2026
5b8c830
fix(UsageLimitModal, Conversations): enhance user messaging for budge…
senamakel Apr 10, 2026
d79f226
refactor(SkillSetupModal): improve session mode handling for skill co…
senamakel Apr 10, 2026
7161e85
feat(SkillManager): enhance setup flow for OAuth-based skills
senamakel Apr 10, 2026
9e43be9
feat(Home): enhance local AI status handling and asset management
senamakel Apr 10, 2026
12ca713
refactor(Credits): update credit balance structure and terminology
senamakel Apr 10, 2026
cdecf6c
feat(Settings): reorganize billing settings and update descriptions
senamakel Apr 10, 2026
5561df3
refactor(Config): change logging level from info to debug for environ…
senamakel Apr 10, 2026
e2f5532
feat(Overlay): implement overlay attention event handling and refacto…
senamakel Apr 10, 2026
4b13cbe
fix(OverlayBubbleChip): reset typewriter animation on new bubble iden…
senamakel Apr 10, 2026
f731e1f
refactor(rest): streamline key_bytes_from_string function and improve…
senamakel Apr 10, 2026
bde0678
enhance(logging): improve color detection logic for terminal output
senamakel Apr 10, 2026
c8e95c7
test(Home): add mock for openhumanLocalAiAssetsStatus in tests
senamakel Apr 10, 2026
5f30cd1
refactor(SkillSetupModal): improve session mode handling and loading …
senamakel Apr 10, 2026
4ae24f8
refactor(Config): simplify logging for invalid proxy scope values
senamakel Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/src-tauri/src/core_process.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;

Expand All @@ -7,6 +8,23 @@ use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};

/// Propagate ANSI color hints to the spawned core child.
///
/// Core's tracing formatter auto-detects color via `stderr.is_terminal()`,
/// but when core runs as a grandchild under `yarn tauri dev` the inherited
/// stderr may not register as a TTY even though the ultimate terminal
/// supports ANSI. If the Tauri process itself is attached to a TTY we
/// forward `FORCE_COLOR=1` so core emits colored log lines; `NO_COLOR`
/// (user opt-out) always wins and short-circuits the propagation.
fn apply_core_color_env(cmd: &mut Command) {
if std::env::var_os("NO_COLOR").is_some() {
return;
}
if std::io::stderr().is_terminal() {
cmd.env("FORCE_COLOR", "1");
}
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreRunMode {
InProcess,
Expand Down Expand Up @@ -119,6 +137,7 @@ impl CoreProcessHandle {
.arg(self.port.to_string());
cmd
};
apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
Expand Down Expand Up @@ -156,6 +175,7 @@ impl CoreProcessHandle {
cmd
};

apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
Expand Down
8 changes: 4 additions & 4 deletions app/src/components/rewards/RewardsCouponSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,18 @@ const RewardsCouponSection = () => {
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
General credits
Promo credits
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
{creditBalance ? formatUsd(creditBalance.balanceUsd) : loading ? '…' : '—'}
{creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
Top-up credits
Team top-up
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
{creditBalance ? formatUsd(creditBalance.topUpBalanceUsd) : loading ? '…' : '—'}
{creditBalance ? formatUsd(creditBalance.teamTopupUsd) : loading ? '…' : '—'}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ describe('RewardsCouponSection', () => {

it('loads balances and refreshes history after a successful redemption', async () => {
mocks.mockCreditsApi.getBalance
.mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 1, topUpBaselineUsd: null })
.mockResolvedValueOnce({ balanceUsd: 8, topUpBalanceUsd: 1, topUpBaselineUsd: null });
.mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 1 })
.mockResolvedValueOnce({ promotionBalanceUsd: 8, teamTopupUsd: 1 });
mocks.mockCreditsApi.getUserCoupons
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
Expand Down Expand Up @@ -70,11 +70,7 @@ describe('RewardsCouponSection', () => {
});

it('shows backend redemption errors without clearing the existing state', async () => {
mocks.mockCreditsApi.getBalance.mockResolvedValue({
balanceUsd: 3,
topUpBalanceUsd: 0,
topUpBaselineUsd: null,
});
mocks.mockCreditsApi.getBalance.mockResolvedValue({ promotionBalanceUsd: 3, teamTopupUsd: 0 });
mocks.mockCreditsApi.getUserCoupons.mockResolvedValue([]);
mocks.mockCreditsApi.redeemCoupon.mockRejectedValueOnce({
error: 'This coupon has already been used.',
Expand All @@ -94,8 +90,8 @@ describe('RewardsCouponSection', () => {

it('shows pending coupon copy and keeps the current balance until the reward is fulfilled', async () => {
mocks.mockCreditsApi.getBalance
.mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null })
.mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null });
.mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 })
.mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 });
mocks.mockCreditsApi.getUserCoupons
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
Expand Down
19 changes: 18 additions & 1 deletion app/src/components/settings/SettingsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const SettingsHome = () => {
{
id: 'account',
title: 'Account & Security',
description: 'Billing, recovery phrase, team management, and linked account access',
description: 'Recovery phrase, team management, and linked account access',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5v14" />
Expand All @@ -88,6 +88,23 @@ const SettingsHome = () => {
onClick: () => navigateToSettings('account'),
dangerous: false,
},
{
id: 'billing',
title: 'Billing & Usage',
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
onClick: () => navigateToSettings('billing'),
dangerous: false,
},
{
id: 'automation',
title: 'Automation & Channels',
Expand Down
5 changes: 4 additions & 1 deletion app/src/components/settings/hooks/useSettingsNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'ai-tools':
return [settingsCrumb];

// Leaf panels under account
// Top-level billing leaf (promoted out of Account & Security)
case 'billing':
return [settingsCrumb];

// Leaf panels under account
case 'recovery-phrase':
case 'team':
case 'connections':
Expand Down
13 changes: 12 additions & 1 deletion app/src/components/settings/panels/ScreenIntelligencePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ComponentProps, useEffect, useMemo, useState } from 'react';
import { type ComponentProps, useEffect, useMemo, useRef, useState } from 'react';

import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
Expand Down Expand Up @@ -79,10 +79,21 @@ const ScreenIntelligencePanel = () => {
const [isSavingConfig, setIsSavingConfig] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);

// CoreStateProvider polls every 2s (CoreStateProvider.tsx POLL_MS), producing a
// new `status` object reference on every tick even when the underlying config is
// unchanged. Keying this effect on `status?.config` identity would therefore
// clobber in-progress user edits every 2 seconds. Compare the serialized value
// instead, so we only re-sync when the server config has actually changed.
const lastSyncedConfigSigRef = useRef<string | null>(null);
useEffect(() => {
if (!status?.config) {
return;
}
const sig = JSON.stringify(status.config);
if (lastSyncedConfigSigRef.current === sig) {
return;
}
lastSyncedConfigSigRef.current = sig;
setEnabled(status.config.enabled ?? false);
setPolicyMode(
status.config.policy_mode === 'whitelist_only' ? 'whitelist_only' : 'all_except_blacklist'
Expand Down
42 changes: 14 additions & 28 deletions app/src/components/settings/panels/billing/PayAsYouGoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ const PayAsYouGoCard = ({
onTopUp,
onBalanceRefresh,
}: PayAsYouGoCardProps) => {
const promoCredits = creditBalance?.balanceUsd ?? 0;
const topUpCredits = creditBalance?.topUpBalanceUsd ?? 0;
const topUpBaseline = creditBalance?.topUpBaselineUsd ?? null;
const availableCredits = promoCredits + topUpCredits;
// Backend `GET /payments/credits/balance` returns
// { promotionBalanceUsd, teamTopupUsd }
// `promotionBalanceUsd` lives on the user document
// (`IUserUsage.promotionBalanceUsd`) and unifies signup bonus, coupons,
// and referral rewards. `teamTopupUsd` is the team-level paid top-up pool.
// Together they make the pay-as-you-go spendable balance.
const promoCredits = creditBalance?.promotionBalanceUsd ?? 0;
const teamTopupCredits = creditBalance?.teamTopupUsd ?? 0;
const availableCredits = promoCredits + teamTopupCredits;

// Coupon state (local — no need to share with other sections)
const [couponCode, setCouponCode] = useState('');
Expand Down Expand Up @@ -78,30 +83,11 @@ const PayAsYouGoCard = ({
<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">
${topUpCredits.toFixed(2)}
{topUpBaseline != null && topUpBaseline > 0 && (
<span className="text-stone-500 font-normal"> / ${topUpBaseline.toFixed(2)}</span>
)}
</span>
</div>
{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 ${
topUpCredits <= 0
? 'bg-coral-500'
: topUpCredits / topUpBaseline < 0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{ width: `${Math.min(100, (topUpCredits / topUpBaseline) * 100)}%` }}
/>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">Team top-up credits</span>
<span className="text-xs font-medium text-stone-900">
${teamTopupCredits.toFixed(2)}
</span>
</div>
</div>
) : isLoadingCredits ? (
Expand Down
42 changes: 33 additions & 9 deletions app/src/components/skills/SkillSetupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,29 @@ export default function SkillSetupModal({
const modalRef = useRef<HTMLDivElement>(null);
const snap = useSkillSnapshot(skillId);
const setupComplete = snap?.setup_complete ?? false;
// Track whether the user has explicitly chosen to reconfigure (setup mode)
// even though setup is already complete.
const [forceSetup, setForceSetup] = useState(false);
// Derive mode: show manage if setup is complete (or no setup needed),
// unless the user explicitly chose to reconfigure.
const mode = forceSetup ? "setup" : (!hasSetup || setupComplete ? "manage" : "setup");
const setMode = (m: "manage" | "setup") => setForceSetup(m === "setup");
// Lock the mode in once we have a concrete snapshot — `useSkillSnapshot`
// returns `null` on the first render while it fetches, so reading
// `setup_complete` at mount time would always see `false` and wrongly
// default an already-connected skill into the setup wizard.
//
// We keep `sessionMode` stable after the first resolution so that an
// OAuth flow that flips `setup_complete` to true mid-wizard does not
// yank the user out of the wizard's own "complete" success screen.
// The user can still switch modes explicitly via `setMode` below
// (e.g. SkillManagementPanel's "Reconfigure" button).
const [sessionMode, setSessionMode] = useState<"manage" | "setup" | null>(
() => (!hasSetup ? "manage" : null),
);

useEffect(() => {
if (sessionMode !== null) return;
// Wait for the first concrete snapshot before deciding.
if (snap === null) return;
setSessionMode(setupComplete ? "manage" : "setup");
}, [sessionMode, snap, setupComplete]);

const setMode = (m: "manage" | "setup") => setSessionMode(m);
const mode = sessionMode;

// Handle escape key
useEffect(() => {
Expand Down Expand Up @@ -71,7 +87,11 @@ export default function SkillSetupModal({
};

const headerTitle =
mode === "manage" ? `Manage ${skillName}` : `Connect ${skillName}`;
mode === null
? skillName
: mode === "manage"
? `Manage ${skillName}`
: `Connect ${skillName}`;

const modalContent = (
<div
Expand Down Expand Up @@ -142,7 +162,11 @@ export default function SkillSetupModal({

{/* Content */}
<div className="p-4">
{mode === "manage" ? (
{mode === null ? (
<div className="flex items-center justify-center py-8 text-sm text-stone-400">
Loading…
</div>
) : mode === "manage" ? (
<SkillManagementPanel
skillId={skillId}
onClose={onClose}
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/upsell/UsageLimitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default function UsageLimitModal({
if (!open) return null;

const bodyText = isBudgetExhausted
? 'Your included weekly inference budget is exhausted. Upgrade your plan or top up credits to continue.'
? `You've hit your weekly limit.${resetTime ? ` It resets ${formatResetTime(resetTime)}.` : ''} Upgrade your plan or top up credits to avoid limits.`
: `You've hit your 10-hour inference rate limit.${resetTime ? ` It resets ${formatResetTime(resetTime)}.` : ''} Upgrade for higher limits.`;

return (
Expand Down
69 changes: 63 additions & 6 deletions app/src/lib/skills/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,59 @@ class SkillManager {
/**
* Start the setup flow for a skill. Returns the first step, or null if
* the skill doesn't implement setup/start (e.g. OAuth-only skills).
*
* OAuth-based skills never instantiate a frontend `SkillRuntime` — they
* live only in the Rust core (see `shared.tsx` "no need to start the
* QuickJS runtime first"). For those we fall back to the core RPC
* `openhuman.skills_setup_start`, mirroring the fallback pattern in
* `notifyOAuthComplete`. If the core errors (e.g. the skill doesn't
* implement `onSetupStart` at all, which is common for pure OAuth
* skills), we treat it as "no more setup steps" — the wizard interprets
* that as success and shows the "Connected!" screen.
*/
async startSetup(skillId: string): Promise<SetupStep | null> {
console.log("[SkillManager] startSetup", skillId);
const runtime = this.runtimes.get(skillId);
if (!runtime) {
console.log("[SkillManager] runtime not found", skillId);
throw new Error(`Skill ${skillId} runtime not found`);
if (runtime) {
emitSkillStateChange(skillId);
console.log("[SkillManager] setup started (local runtime)", skillId);
return runtime.setupStart();
}

emitSkillStateChange(skillId);
console.log("[SkillManager] setup started", skillId);
return runtime.setupStart();
// No frontend runtime — dispatch via core RPC pass-through.
//
// The core side returns `null` (not an error) when the skill has no
// `onSetupStart` handler — see `handle_js_call` in
// `src/openhuman/skills/qjs_skill_instance/js_handlers.rs`, which
// falls through to `return "null"` when the function is missing.
// So any exception thrown here is a *real* failure (skill not
// running, RPC transport error, JS exception in the handler, …)
// and must be propagated so the caller can surface it to the user
// instead of silently pretending setup succeeded.
try {
const result = (await callCoreRpc({
method: "openhuman.skills_setup_start",
params: { skill_id: skillId },
})) as { step?: SetupStep } | null;
emitSkillStateChange(skillId);
console.log(
"[SkillManager] setup started (core RPC fallback)",
skillId,
result,
);
if (!result || !result.step) {
return null;
}
return result.step;
} catch (err) {
console.warn(
"[SkillManager] setup_start core fallback failed",
skillId,
err,
);
emitSkillStateChange(skillId);
throw err;
}
}

/**
Expand Down Expand Up @@ -344,6 +385,22 @@ class SkillManager {
}
}

// Kick off an initial sync so the user sees fresh data immediately
// after connecting, rather than waiting for the next cron tick.
// The Rust core no longer auto-triggers sync on oauth/complete
// (removed in commit 840b1d3c), so the frontend drives it here.
// Fire-and-forget: any failure is logged but must not block the
// OAuth completion flow.
try {
console.log(`[SkillManager] kicking initial sync after OAuth for '${skillId}'`);
await this.triggerSync(skillId);
} catch (syncErr) {
console.warn(
`[SkillManager] initial post-OAuth sync failed for '${skillId}':`,
syncErr,
);
}

emitSkillStateChange(skillId);
}

Expand Down
Loading
Loading