diff --git a/.env.example b/.env.example index 413a32110..4af824a5c 100644 --- a/.env.example +++ b/.env.example @@ -4,13 +4,23 @@ # # Tags: [required] must be set, [optional] has a sensible default or can be blank + +# --------------------------------------------------------------------------- +# App environment +# --------------------------------------------------------------------------- +# [optional] set to either 'production' or 'staging' +OPENHUMAN_APP_ENV=staging + # --------------------------------------------------------------------------- # Backend API # --------------------------------------------------------------------------- +# [optional] App environment selector for default paths/URLs: production | staging +OPENHUMAN_APP_ENV=staging # [required] Primary backend URL (read by Rust core and QuickJS skills sandbox) -BACKEND_URL=https://staging-api.alphahuman.xyz +BACKEND_URL=https://staging-api.tinyhumans.ai # [required] Also read by Vite frontend (VITE_ prefix required for browser exposure) -VITE_BACKEND_URL=https://staging-api.alphahuman.xyz +VITE_OPENHUMAN_APP_ENV=staging +VITE_BACKEND_URL=https://staging-api.tinyhumans.ai # --------------------------------------------------------------------------- # Authentication (for skills OAuth proxy and debug scripts) @@ -46,7 +56,7 @@ OPENHUMAN_CORE_BIN= OPENHUMAN_API_KEY= # [optional] Default model to use OPENHUMAN_MODEL= -# [optional] Workspace directory (default: ~/.openhuman) +# [optional] Workspace directory (default: ~/.openhuman or ~/.openhuman-staging when OPENHUMAN_APP_ENV=staging) OPENHUMAN_WORKSPACE= # [optional] Default: 0.7 OPENHUMAN_TEMPERATURE=0.7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb5298056..a43d669e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -271,6 +271,13 @@ jobs: fetch-depth: 1 submodules: true + - name: Configure staging app environment + if: inputs.build_target == 'staging' + shell: bash + run: | + echo "OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV" + echo "VITE_OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV" + - name: Set Xcode version if: matrix.settings.platform == 'macos-latest' uses: maxim-lobanov/setup-xcode@v1 @@ -368,6 +375,7 @@ jobs: run: yarn build env: NODE_ENV: production + VITE_OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_DEBUG: ${{ vars.VITE_DEBUG }} @@ -414,6 +422,7 @@ jobs: MATRIX_TARGET: ${{ matrix.settings.target }} CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }} CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }} + OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} OPENHUMAN_SENTRY_DSN: ${{ vars.OPENHUMAN_SENTRY_DSN }} - name: Stage sidecar for Tauri bundler @@ -452,6 +461,8 @@ jobs: # where we sign everything with hardened runtime + entitlements # (required by Apple notarization). BASE_URL: ${{ needs.prepare-build.outputs.base_url }} + OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} + VITE_OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} @@ -474,6 +485,8 @@ jobs: shell: bash env: BASE_URL: ${{ needs.prepare-build.outputs.base_url }} + OPENHUMAN_APP_ENV: staging + VITE_OPENHUMAN_APP_ENV: staging VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} @@ -662,8 +675,8 @@ jobs: if: >- always() && needs.prepare-build.result == 'success' - && (needs.prepare-build.outputs.release_enabled != 'true' - || needs.create-release.result == 'success') + && needs.prepare-build.outputs.release_enabled == 'true' + && needs.create-release.result == 'success' runs-on: ubuntu-latest environment: Production env: diff --git a/app/.env.example b/app/.env.example index c14356ce1..d95ca3d21 100644 --- a/app/.env.example +++ b/app/.env.example @@ -4,11 +4,14 @@ # # Tags: [required] must be set, [optional] has a sensible default or can be blank +# [optional] App environment selector for default backend fallback: production | staging +VITE_OPENHUMAN_APP_ENV=staging + # [optional] Core RPC endpoint (Tauri sets this automatically; override for web-only dev) VITE_OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc # [required] Backend API URL (web fallback when core RPC is unavailable) -VITE_BACKEND_URL=https://staging-api.alphahuman.xyz +VITE_BACKEND_URL=https://staging-api.tinyhumans.ai # [optional] Telegram bot username used for managed DM linking fallback (default: openhuman_bot) VITE_TELEGRAM_BOT_USERNAME=openhuman_bot diff --git a/app/package.json b/app/package.json index 24e370467..5e4648ac2 100644 --- a/app/package.json +++ b/app/package.json @@ -67,6 +67,7 @@ "process": "^0.11.10", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.0", diff --git a/app/src/components/PillTabBar.tsx b/app/src/components/PillTabBar.tsx new file mode 100644 index 000000000..5e542cecd --- /dev/null +++ b/app/src/components/PillTabBar.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; + +interface PillTabBarItem { + label: string; + value: T; +} + +interface PillTabBarProps { + activeClassName?: string; + containerClassName?: string; + inactiveClassName?: string; + items: PillTabBarItem[]; + onChange: (value: T) => void; + renderItem?: (item: PillTabBarItem, active: boolean) => ReactNode; + selected: T; +} + +export default function PillTabBar({ + activeClassName = 'border-primary-200 bg-primary-50 text-primary-700', + containerClassName = 'flex gap-2 overflow-x-auto pb-1 scrollbar-hide', + inactiveClassName = 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50', + items, + onChange, + renderItem, + selected, +}: PillTabBarProps) { + return ( +
+ {items.map(item => { + const active = selected === item.value; + const tabId = `pill-tab-${String(item.value)}`; + + return ( + + ); + })} +
+ ); +} diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx index 70ba802f7..b8c229385 100644 --- a/app/src/components/composio/ComposioConnectModal.tsx +++ b/app/src/components/composio/ComposioConnectModal.tsx @@ -251,9 +251,8 @@ export default function ComposioConnectModal({ {phase === 'idle' && ( <>

- Connect your {toolkit.name} account through Composio. We will open a browser window - where you can grant access, and then this app will detect the connection - automatically. + Connect your {toolkit.name} account. We will open a browser window where you can + grant access, and then this app will detect the connection automatically.

+ + )} + + ) : ( +
+ {isRunning && ( +
+
+ Analyzing your data… +
+ )} + {timeGroups.map((group, groupIndex) => ( +
+
+

{group.label}

+
+ {group.count} +
+
+
+ {group.items.map((item, itemIndex) => ( +
+ +
+ ))} +
+
+ ))} +
+ )} + + ); +} diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx new file mode 100644 index 000000000..bf0dbf826 --- /dev/null +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -0,0 +1,454 @@ +import type { Dispatch, FormEvent, SetStateAction } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { + SubconsciousEscalation, + SubconsciousLogEntry, + SubconsciousStatus, + SubconsciousTask, +} from '../../utils/tauriCommands/subconscious'; + +const SKILL_KEYWORDS = + /\bskill\b|\boauth\b|\bnotion\b|\bgmail\b|\bintegration\b|\bdisconnect|\breconnect|\bre-?auth/i; + +function isSkillRelated(title: string, description: string): boolean { + return SKILL_KEYWORDS.test(title) || SKILL_KEYWORDS.test(description); +} + +interface IntelligenceSubconsciousTabProps { + addSubconsciousTask: (title: string) => Promise; + approveEscalation: (escalationId: string) => Promise; + dismissEscalation: (escalationId: string) => Promise; + expandedLogIds: Set; + logEntries: SubconsciousLogEntry[]; + newTaskTitle: string; + removeSubconsciousTask: (taskId: string) => Promise; + setExpandedLogIds: Dispatch>>; + setNewTaskTitle: (value: string) => void; + status: SubconsciousStatus | null; + tasks: SubconsciousTask[]; + toggleSubconsciousTask: (taskId: string, enabled: boolean) => Promise; + triggerTick: () => Promise; + triggering: boolean; + escalations: SubconsciousEscalation[]; + loading: boolean; +} + +export default function IntelligenceSubconsciousTab({ + addSubconsciousTask, + approveEscalation, + dismissEscalation, + escalations, + expandedLogIds, + loading, + logEntries, + newTaskTitle, + removeSubconsciousTask, + setExpandedLogIds, + setNewTaskTitle, + status, + tasks, + toggleSubconsciousTask, + triggerTick, + triggering, +}: IntelligenceSubconsciousTabProps) { + const navigate = useNavigate(); + + const handleAddTask = async (e: FormEvent) => { + e.preventDefault(); + const title = newTaskTitle.trim(); + if (!title) return; + console.debug('[subconscious-ui] add task:start', { title }); + try { + await addSubconsciousTask(title); + setNewTaskTitle(''); + console.debug('[subconscious-ui] add task:success', { title }); + } catch (error) { + console.debug('[subconscious-ui] add task:error', { + title, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleRunTick = async () => { + console.debug('[subconscious-ui] run tick:start', { triggering }); + try { + await triggerTick(); + console.debug('[subconscious-ui] run tick:done'); + } catch (error) { + console.debug('[subconscious-ui] run tick:error', { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleApproveEscalation = async (escalationId: string) => { + console.debug('[subconscious-ui] escalation approve:start', { escalationId }); + try { + await approveEscalation(escalationId); + console.debug('[subconscious-ui] escalation approve:success', { escalationId }); + } catch (error) { + console.debug('[subconscious-ui] escalation approve:error', { + escalationId, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleDismissEscalation = async (escalationId: string) => { + console.debug('[subconscious-ui] escalation dismiss:start', { escalationId }); + try { + await dismissEscalation(escalationId); + console.debug('[subconscious-ui] escalation dismiss:success', { escalationId }); + } catch (error) { + console.debug('[subconscious-ui] escalation dismiss:error', { + escalationId, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleFixInSkills = (escalationId: string) => { + console.debug('[subconscious-ui] escalation fix in skills:navigate', { escalationId }); + navigate('/skills', { state: { subconsciousEscalationId: escalationId } }); + }; + + const handleToggleTask = async (taskId: string, enabled: boolean, title: string) => { + console.debug('[subconscious-ui] task toggle:start', { taskId, enabled, title }); + try { + await toggleSubconsciousTask(taskId, enabled); + console.debug('[subconscious-ui] task toggle:success', { taskId, enabled, title }); + } catch (error) { + console.debug('[subconscious-ui] task toggle:error', { + taskId, + enabled, + title, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleRemoveTask = async (taskId: string, title: string) => { + console.debug('[subconscious-ui] task remove:start', { taskId, title }); + try { + await removeSubconsciousTask(taskId); + console.debug('[subconscious-ui] task remove:success', { taskId, title }); + } catch (error) { + console.debug('[subconscious-ui] task remove:error', { + taskId, + title, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + return ( +
+
+
+ {status && ( + <> + {status.task_count} tasks + | + {status.total_ticks} ticks + {status.last_tick_at && ( + <> + | + Last: {new Date(status.last_tick_at * 1000).toLocaleTimeString()} + + )} + {status.consecutive_failures > 0 && ( + <> + | + {status.consecutive_failures} failed + + )} + + )} +
+
+
+ + + + +
+ +
+
+ + {escalations.length > 0 && ( +
+

+ + Approval Needed + + {escalations.length} + +

+
+ {escalations.map(esc => ( +
+
+
+

{esc.title}

+

{esc.description}

+
+ + {esc.priority} + + + Requires your approval to proceed + +
+
+
+ {isSkillRelated(esc.title, esc.description) ? ( + + ) : ( + + )} + +
+
+
+ ))} +
+
+ )} + +
+

Active Tasks

+ {loading && tasks.length === 0 ? ( +
+
+
+ ) : tasks.filter(t => !t.completed).length === 0 ? ( +

No active tasks. Add one below.

+ ) : ( +
+ {tasks + .filter(t => !t.completed && t.source === 'system') + .map(task => ( +
+
+ {task.title} + + default + +
+ ))} + {tasks + .filter(t => !t.completed && t.source !== 'system') + .map(task => ( +
+
+ + + {task.title} + +
+ +
+ ))} +
+ )} + +
void handleAddTask(e)} className="flex gap-2 mt-3"> + setNewTaskTitle(e.target.value)} + className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors" + /> + +
+
+ +
+

Activity Log

+ {logEntries.length === 0 ? ( +

No activity yet. Run a tick to see results.

+ ) : ( +
+ {logEntries.map(entry => ( +
+ + {new Date(entry.tick_at * 1000).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + + 120 ? 'cursor-pointer hover:text-stone-900' : ''}`} + onClick={() => { + if (entry.result && entry.result.length > 120) { + setExpandedLogIds(prev => { + const next = new Set(prev); + if (next.has(entry.id)) next.delete(entry.id); + else next.add(entry.id); + return next; + }); + } + }}> + {entry.result + ? expandedLogIds.has(entry.id) + ? entry.result + : entry.result.length > 120 + ? `${entry.result.substring(0, 120)}...` + : entry.result + : entry.decision === 'noop' + ? 'Nothing new' + : entry.decision === 'act' + ? 'Completed' + : entry.decision === 'in_progress' + ? 'Evaluating...' + : entry.decision === 'escalate' + ? 'Waiting for approval' + : entry.decision === 'failed' + ? 'Failed' + : entry.decision === 'cancelled' + ? 'Cancelled' + : entry.decision === 'dismissed' + ? 'Skipped' + : entry.decision} + + {entry.duration_ms != null && ( + + {entry.duration_ms > 1000 + ? `${(entry.duration_ms / 1000).toFixed(1)}s` + : `${entry.duration_ms}ms`} + + )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/src/components/referral/ReferralRewardsSection.tsx b/app/src/components/rewards/ReferralRewardsSection.tsx similarity index 57% rename from app/src/components/referral/ReferralRewardsSection.tsx rename to app/src/components/rewards/ReferralRewardsSection.tsx index 121200923..af3b00a19 100644 --- a/app/src/components/referral/ReferralRewardsSection.tsx +++ b/app/src/components/rewards/ReferralRewardsSection.tsx @@ -126,10 +126,11 @@ const ReferralRewardsSection = () => { const handleApply = async () => { const trimmed = applyCode.trim(); if (!trimmed) return; + const normalizedValue = /^https?:\/\//i.test(trimmed) ? trimmed : trimmed.toUpperCase(); setApplyLoading(true); setApplyError(null); try { - await referralApi.claimReferral(trimmed); + await referralApi.claimReferral(normalizedValue); setApplySuccess(true); setApplyCode(''); await refetch(); @@ -161,96 +162,135 @@ const ReferralRewardsSection = () => { } return ( -
-
-
-
- Referral rewards +
+
+
+
+

Invite friends, earn credits

+

+ Share your personal link. When a friend subscribes to a monthly plan, you both get $5 + in account credit. Self-referrals and duplicate rewards are blocked on the server. +

-

Invite friends, earn credits

-

- Share your personal link. When a friend subscribes to a monthly plan, you both get $5 in - account credit. Self-referrals and duplicate rewards are blocked on the server. -

+ + {loading && !stats ? ( +

Loading referral program…

+ ) : null} + {loadError ? ( +
+ {loadError} + +
+ ) : null} + + {stats ? ( + <> +
+
+
+ Your code +
+
+ {stats.referralCode || '—'} +
+
+
+
+ Total earned +
+
+ {formatUsd(stats.totals.totalRewardUsd)} +
+
+
+
+ Pending referrals +
+
+ {stats.totals.pendingCount} +
+
+
+
+ Completed +
+
+ {stats.totals.convertedCount} +
+
+
+ +
+ + + {copyHint ? ( + {copyHint} + ) : null} +
+ + {stats.referralLink ? ( +

{stats.referralLink}

+ ) : null} + + ) : null}
- {loading && !stats ? ( -

Loading referral program…

- ) : null} - {loadError ? ( -
- {loadError} - + {stats && stats.canApplyReferral !== false && showApplyForm ? ( +
+

Have a referral code?

+

+ Enter a friend's referral code. You're eligible if you haven't subscribed + yet — once you subscribe, you'll both get $5 in credit. +

+
+ setApplyCode(e.target.value)} + onKeyDown={e => e.key === 'Enter' && void handleApply()} + placeholder="Referral code or link" + disabled={applyLoading} + className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40" + /> + +
+ {applyError ?

{applyError}

: null}
) : null} + {stats && (hasAppliedFromStats || hasAppliedFromProfile || applySuccess) && !showApplyForm ? ( +

+ You're linked to a referral program + {stats.appliedReferralCode ? ` (code ${stats.appliedReferralCode})` : ''}. +

+ ) : null} + {stats ? ( - <> -
-
-
- Your code -
-
- {stats.referralCode || '—'} -
-
-
-
- Total earned -
-
- {formatUsd(stats.totals.totalRewardUsd)} -
-
-
-
- Pending referrals -
-
- {stats.totals.pendingCount} -
-
-
-
- Completed -
-
- {stats.totals.convertedCount} -
-
-
- -
- - - {copyHint ? ( - {copyHint} - ) : null} -
- - {stats.referralLink ? ( -

{stats.referralLink}

- ) : null} - +

Referral activity

{stats.referrals.length === 0 ? ( @@ -299,43 +339,7 @@ const ReferralRewardsSection = () => {
)}
- - {showApplyForm ? ( -
-

Have a referral code?

-

- Enter a friend's referral code. You're eligible if you haven't - subscribed yet — once you subscribe, you'll both get $5 in credit. -

-
- setApplyCode(e.target.value.toUpperCase())} - onKeyDown={e => e.key === 'Enter' && void handleApply()} - placeholder="Referral code" - disabled={applyLoading} - className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40" - /> - -
- {applyError ?

{applyError}

: null} -
- ) : null} - - {(hasAppliedFromStats || hasAppliedFromProfile || applySuccess) && !showApplyForm ? ( -

- You're linked to a referral program - {stats.appliedReferralCode ? ` (code ${stats.appliedReferralCode})` : ''}. -

- ) : null} - +
) : null}
); diff --git a/app/src/components/rewards/RewardsCommunityTab.tsx b/app/src/components/rewards/RewardsCommunityTab.tsx new file mode 100644 index 000000000..9e19d17b2 --- /dev/null +++ b/app/src/components/rewards/RewardsCommunityTab.tsx @@ -0,0 +1,283 @@ +import { useNavigate } from 'react-router-dom'; + +import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards'; +import { DISCORD_INVITE_URL } from '../../utils/links'; + +function discordMembershipLabel(snapshot: RewardsSnapshot | null): string { + if (!snapshot) return 'Waiting for backend sync'; + switch (snapshot.discord.membershipStatus) { + case 'member': + return 'Joined the server'; + case 'not_in_guild': + return 'Linked, but not in server'; + case 'not_linked': + return 'Not linked'; + default: + return 'Membership status unavailable'; + } +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat('en-US').format(Math.max(0, Math.trunc(value))); +} + +function roleAccentTone(index: number) { + const tones = [ + { iconBg: 'bg-amber-50', iconText: 'text-amber-600', iconBorder: 'border-amber-100' }, + { iconBg: 'bg-blue-50', iconText: 'text-primary-600', iconBorder: 'border-blue-100' }, + { iconBg: 'bg-slate-100', iconText: 'text-slate-600', iconBorder: 'border-slate-200' }, + { iconBg: 'bg-emerald-50', iconText: 'text-emerald-600', iconBorder: 'border-emerald-100' }, + ] as const; + + return tones[index % tones.length]; +} + +function roleGlyph(index: number) { + switch (index % 4) { + case 0: + return ( + + ); + case 1: + return ( + + ); + case 2: + return ( + + ); + default: + return ( + + ); + } +} + +interface RewardsCommunityTabProps { + error: string | null; + isLoading: boolean; + snapshot: RewardsSnapshot | null; +} + +export default function RewardsCommunityTab({ + error, + isLoading, + snapshot, +}: RewardsCommunityTabProps) { + const navigate = useNavigate(); + const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? []; + const unlocked = + snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length; + const total = snapshot?.summary.totalCount ?? rewardRoles.length; + const inviteUrl = snapshot?.discord.inviteUrl ?? DISCORD_INVITE_URL; + const progressPercent = total > 0 ? Math.round((unlocked / total) * 100) : 0; + const achievementSlots = + rewardRoles.length > 0 ? rewardRoles.slice(0, 8) : new Array(4).fill(null); + const ringCircumference = 2 * Math.PI * 24; + const ringOffset = ringCircumference - (progressPercent / 100) * ringCircumference; + return ( + <> +
+
+
+

+ Earn Rewards & Discord Roles +

+

+ Unlock exclusive channels, supporter badges, and backend-synced rewards by connecting + your Discord account. +

+
+
+ + +
+
+
+
+
+ + {error ? ( +
+ Rewards sync is unavailable right now. The page is showing connection guidance without + claiming new unlocks. Details: {error} +
+ ) : null} + +
+
+
+
+

Your Progress

+

+ {isLoading ? 'Loading rewards…' : `${unlocked} of ${total} achievements unlocked`} +

+
+
+ + {progressPercent}% +
+
+ +
+ {achievementSlots.map((role, index) => ( +
+ +
+ ))} +
+
+ +
+
+

Roles & Rewards

+
+ {isLoading ? ( +
+
Loading rewards…
+
+ ) : rewardRoles.length > 0 ? ( + rewardRoles.map((role, index) => { + const tone = roleAccentTone(index); + + return ( +
+
+
+
+ +
+
+

{role.title}

+

+ {role.description} +

+
+
+
+ + {role.unlocked ? 'Unlocked' : 'Locked'} + + +
+
+
+ ); + }) + ) : ( +
+

Rewards sync pending

+

+ The backend did not return achievement data yet. Join Discord and connect your + account now, then refresh this page once sync is available again. +

+
+ )} +
+ +
+
+ Discord server + {discordMembershipLabel(snapshot)} +
+
+ Current streak + + {snapshot ? `${snapshot.metrics.currentStreakDays} days` : 'Unknown'} + +
+
+ Cumulative tokens + + {snapshot ? formatNumber(snapshot.metrics.cumulativeTokens) : 'Unknown'} + +
+
+
+ + ); +} diff --git a/app/src/components/rewards/RewardsCouponSection.tsx b/app/src/components/rewards/RewardsCouponSection.tsx index dfed4ad88..6ffd26702 100644 --- a/app/src/components/rewards/RewardsCouponSection.tsx +++ b/app/src/components/rewards/RewardsCouponSection.tsx @@ -147,149 +147,142 @@ const RewardsCouponSection = () => { } return ( -
-
-
- Promo and reward codes + <> +
+
+

Redeem a coupon code

+

+ Redeem promo or coupon codes here. Successful redemptions refresh your credits + immediately, and pending rewards stay visible in your history. +

-

Apply a reward code

-

- Redeem promo or campaign codes here. Referral attribution stays in the referral section - above. Successful redemptions refresh your credits immediately, and pending rewards stay - visible in your history. -

-
-
-
-
- Promo credits +
+
+
+ Promo credits +
+
+ {creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'} +
-
- {creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'} +
+
+ Redeemed codes +
+
+ {redeemedCoupons.length} +
-
-
- Team top-up -
-
- {creditBalance ? formatUsd(creditBalance.teamTopupUsd) : loading ? '…' : '—'} -
-
-
-
- Redeemed codes -
-
{redeemedCoupons.length}
-
-
-
-
- { - setCouponCode(event.target.value.toUpperCase()); - if (submitError) setSubmitError(null); - if (submitSuccess) setSubmitSuccess(null); - }} - onKeyDown={event => { - if (event.key === 'Enter') { - void handleRedeem(); - } - }} - placeholder="Promo code" - disabled={submitLoading} - className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40" - /> - +
+
+ { + setCouponCode(event.target.value.toUpperCase()); + if (submitError) setSubmitError(null); + if (submitSuccess) setSubmitSuccess(null); + }} + onKeyDown={event => { + if (event.key === 'Enter') { + void handleRedeem(); + } + }} + placeholder="Coupon code" + disabled={submitLoading} + className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40" + /> + +
+ {submitSuccess ? ( +
+ {submitSuccess} +
+ ) : null} + {submitError ? ( +
+ {submitError} +
+ ) : null} + {loadError ? ( +
+ {loadError} + +
+ ) : null}
- {submitSuccess ? ( -
- {submitSuccess} -
- ) : null} - {submitError ? ( -
- {submitError} -
- ) : null} - {loadError ? ( -
- {loadError} +
+
+
+
+

Recent redemptions

- ) : null} -
-
-
-

Recent redemptions

- -
+ {loading && redeemedCoupons.length === 0 ? ( +

Loading reward history…

+ ) : null} - {loading && redeemedCoupons.length === 0 ? ( -

Loading reward history…

- ) : null} - - {redeemedCoupons.length === 0 && !loading && !loadError ? ( -

- No reward codes redeemed yet. -

- ) : redeemedCoupons.length > 0 ? ( -
- - - - - - - - - - - {redeemedCoupons.map(coupon => ( - - - - - + {redeemedCoupons.length === 0 && !loading && !loadError ? ( +

+ No reward codes redeemed yet. +

+ ) : redeemedCoupons.length > 0 ? ( +
+
CodeRewardStatusRedeemed
{coupon.code}{formatUsd(coupon.amountUsd)} - - {redemptionStatus(coupon)} - - - {formatDateTime(coupon.redeemedAt)} -
+ + + + + + - ))} - -
CodeRewardStatusRedeemed
-
- ) : null} -
-
+ + + {redeemedCoupons.map(coupon => ( + + {coupon.code} + {formatUsd(coupon.amountUsd)} + + + {redemptionStatus(coupon)} + + + + {formatDateTime(coupon.redeemedAt)} + + + ))} + + +
+ ) : null} +
+ + ); }; diff --git a/app/src/components/rewards/RewardsRedeemTab.tsx b/app/src/components/rewards/RewardsRedeemTab.tsx new file mode 100644 index 000000000..f59b732ac --- /dev/null +++ b/app/src/components/rewards/RewardsRedeemTab.tsx @@ -0,0 +1,9 @@ +import RewardsCouponSection from './RewardsCouponSection'; + +export default function RewardsRedeemTab() { + return ( + <> + + + ); +} diff --git a/app/src/components/rewards/RewardsReferralsTab.tsx b/app/src/components/rewards/RewardsReferralsTab.tsx new file mode 100644 index 000000000..c04cc4a7b --- /dev/null +++ b/app/src/components/rewards/RewardsReferralsTab.tsx @@ -0,0 +1,9 @@ +import ReferralRewardsSection from './ReferralRewardsSection'; + +export default function RewardsReferralsTab() { + return ( + <> + + + ); +} diff --git a/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx b/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx index cd753e99a..684badbeb 100644 --- a/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx +++ b/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx @@ -54,8 +54,10 @@ describe('RewardsCouponSection', () => { expect(await screen.findByText('$3.00')).toBeInTheDocument(); expect(screen.getByText('No reward codes redeemed yet.')).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'aprl-2026' } }); - fireEvent.click(screen.getByRole('button', { name: 'Apply code' })); + fireEvent.change(screen.getByPlaceholderText('Coupon code'), { + target: { value: 'aprl-2026' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' })); expect( await screen.findByText('APRL-2026 redeemed. $5.00 was added to your credits.') @@ -80,8 +82,10 @@ describe('RewardsCouponSection', () => { expect(await screen.findByText('$3.00')).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'used-code' } }); - fireEvent.click(screen.getByRole('button', { name: 'Apply code' })); + fireEvent.change(screen.getByPlaceholderText('Coupon code'), { + target: { value: 'used-code' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' })); expect(await screen.findByText('This coupon has already been used.')).toBeInTheDocument(); expect(mocks.mockCreditsApi.getBalance).toHaveBeenCalledTimes(1); @@ -115,8 +119,10 @@ describe('RewardsCouponSection', () => { expect(await screen.findByText('$3.00')).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'aprl-2026' } }); - fireEvent.click(screen.getByRole('button', { name: 'Apply code' })); + fireEvent.change(screen.getByPlaceholderText('Coupon code'), { + target: { value: 'aprl-2026' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Redeem Code' })); expect( await screen.findByText( diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 144bb10f1..4cc8df29d 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -69,8 +69,8 @@ const SettingsHome = () => { const groupedMenuItems = [ { id: 'account', - title: 'Account & Billing', - description: 'Recovery phrase, team, connections, billing, and privacy', + title: 'Account', + description: 'Recovery phrase, team, connections, and privacy', icon: ( { onClick: () => navigateToSettings('account'), dangerous: false, }, + { + id: 'billing', + title: 'Billing & Usage', + description: 'Subscription plan, pay-as-you-go credits, and payment methods', + icon: ( + + + + ), + onClick: () => navigateToSettings('billing'), + dangerous: false, + }, { id: 'features', title: 'Features', diff --git a/app/src/components/settings/components/PageBackButton.tsx b/app/src/components/settings/components/PageBackButton.tsx new file mode 100644 index 000000000..0e6b3849d --- /dev/null +++ b/app/src/components/settings/components/PageBackButton.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; + +interface PageBackButtonProps { + label: string; + onClick: () => void; + trailingContent?: ReactNode; +} + +const PageBackButton = ({ label, onClick, trailingContent }: PageBackButtonProps) => { + return ( +
+ + {trailingContent} +
+ ); +}; + +export default PageBackButton; diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 76ca688f4..77287bef7 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -134,7 +134,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { const settingsCrumb: BreadcrumbItem = { label: 'Settings', onClick: () => navigate('/settings') }; const accountCrumb: BreadcrumbItem = { - label: 'Account & Billing', + label: 'Account', onClick: () => navigate('/settings/account'), }; @@ -163,14 +163,16 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'ai-models': return [settingsCrumb]; - // Leaf panels under account & billing + // Leaf panels under account case 'recovery-phrase': case 'team': case 'connections': - case 'billing': case 'privacy': return [settingsCrumb, accountCrumb]; + case 'billing': + return [settingsCrumb]; + // Leaf panels under features case 'screen-intelligence': case 'autocomplete': diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index f44bd248c..563b01233 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -1,32 +1,35 @@ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import PillTabBar from '../../../components/PillTabBar'; import { useCoreState } from '../../../providers/CoreStateProvider'; import { billingApi } from '../../../services/api/billingApi'; import { type AutoRechargeSettings, type CreditBalance, creditsApi, + type CreditTransaction, type SavedCard, - type TeamUsage, } from '../../../services/api/creditsApi'; import type { CurrentPlanData, PlanTier } from '../../../types/api'; import { openUrl } from '../../../utils/openUrl'; -import SettingsHeader from '../components/SettingsHeader'; +import PageBackButton from '../components/PageBackButton'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import AutoRechargeSection from './billing/AutoRechargeSection'; -import InferenceBudget from './billing/InferenceBudget'; -import PayAsYouGoCard from './billing/PayAsYouGoCard'; -import SubscriptionPlans from './billing/SubscriptionPlans'; -import { buildPlanId, formatUsdAmount, getPlanMeta } from './billingHelpers'; +import BillingHistoryTab from './billing/BillingHistoryTab'; +import BillingPaymentsTab from './billing/BillingPaymentsTab'; +import BillingPlansTab from './billing/BillingPlansTab'; +import { buildPlanId } from './billingHelpers'; const log = createDebug('openhuman:billing-panel'); +type BillingTab = 'overview' | 'plans' | 'payments' | 'history'; + // ── Component ─────────────────────────────────────────────────────────── const BillingPanel = () => { const { navigateBack, breadcrumbs } = useSettingsNavigation(); const { snapshot, teams, refresh } = useCoreState(); const user = snapshot.currentUser; + const sessionToken = snapshot.sessionToken; // Active team context const activeTeamId = user?.activeTeamId; @@ -36,9 +39,10 @@ const BillingPanel = () => { // Credits & usage state const [currentPlan, setCurrentPlan] = useState(null); const [creditBalance, setCreditBalance] = useState(null); - const [teamUsage, setTeamUsage] = useState(null); + const [transactions, setTransactions] = useState([]); const [isLoadingCredits, setIsLoadingCredits] = useState(false); const [isToppingUp, setIsToppingUp] = useState(false); + const [selectedTab, setSelectedTab] = useState('plans'); // Local state const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly'); @@ -82,18 +86,25 @@ const BillingPanel = () => { currentPlan?.hasActiveSubscription ?? activeTeam?.team.subscription?.hasActiveSubscription ?? false; - const planExpiry = currentPlan?.planExpiry ?? activeTeam?.team.subscription?.planExpiry ?? null; - const currentPlanMeta = getPlanMeta(currentTier); - - // Fetch current plan, credits balance, and team usage on mount + // Fetch current plan, credits balance, and team usage once auth is available. useEffect(() => { + if (!sessionToken) { + log('[load] skipped: no session token yet'); + setCurrentPlan(null); + setCreditBalance(null); + setIsLoadingCredits(false); + return; + } + + let cancelled = false; setIsLoadingCredits(true); + log('[load] fetching billing state tokenPresent=%s activeTeamId=%s', true, activeTeamId); Promise.allSettled([ billingApi.getCurrentPlan(), creditsApi.getBalance(), - creditsApi.getTeamUsage(), + creditsApi.getTransactions(5, 0), ]) - .then(([planResult, balanceResult, usageResult]) => { + .then(([planResult, balanceResult, transactionsResult]) => { if (planResult.status === 'fulfilled') { const plan = planResult.value; log( @@ -102,23 +113,42 @@ const BillingPanel = () => { plan.hasActiveSubscription, plan.weeklyBudgetUsd ); - setCurrentPlan(plan); + if (!cancelled) { + setCurrentPlan(plan); + } } else { log('[load] getCurrentPlan failed: %O', planResult.reason); } if (balanceResult.status === 'fulfilled') { - setCreditBalance(balanceResult.value); + log( + '[load] balance promotion=%s teamTopup=%s', + balanceResult.value.promotionBalanceUsd, + balanceResult.value.teamTopupUsd + ); + if (!cancelled) { + setCreditBalance(balanceResult.value); + } } else { log('[load] getBalance failed: %O', balanceResult.reason); } - if (usageResult.status === 'fulfilled') { - setTeamUsage(usageResult.value); + if (transactionsResult.status === 'fulfilled') { + if (!cancelled) { + setTransactions(transactionsResult.value.transactions); + } } else { - log('[load] getTeamUsage failed: %O', usageResult.reason); + log('[load] getTransactions failed: %O', transactionsResult.reason); } }) - .finally(() => setIsLoadingCredits(false)); - }, []); + .finally(() => { + if (!cancelled) { + setIsLoadingCredits(false); + } + }); + + return () => { + cancelled = true; + }; + }, [sessionToken, activeTeamId]); // When crypto is selected, force annual useEffect(() => { @@ -172,7 +202,7 @@ const BillingPanel = () => { const nextEnabled = !arSettings.enabled; if (nextEnabled && !arSettings.hasSavedPaymentMethod && cards.length === 0) { - setArError('Add a payment card before enabling auto-recharge.'); + setArError('Add a payment card on Stripe before enabling auto-recharge.'); return; } @@ -269,6 +299,13 @@ const BillingPanel = () => { const plan = await billingApi.getCurrentPlan(); log('[payment-success] plan=%s active=%s', plan.plan, plan.hasActiveSubscription); setCurrentPlan(plan); + const balance = await creditsApi.getBalance(); + log( + '[payment-success] refreshed balance promotion=%s teamTopup=%s', + balance.promotionBalanceUsd, + balance.teamTopupUsd + ); + setCreditBalance(balance); } catch (e) { console.error('Failed to fetch current plan after payment', e); } @@ -368,100 +405,35 @@ const BillingPanel = () => { } }; - const handleBalanceRefresh = useCallback(async () => { - try { - const balance = await creditsApi.getBalance(); - setCreditBalance(balance); - } catch (err) { - log('[balance-refresh] failed: %O', err); - } - }, []); + const transactionRows = transactions.slice(0, 4); // ── JSX ───────────────────────────────────────────────────────────── return ( -
- - -
-
- {/* ── Current Plan Header ───────────────────────────────── */} -
-
-

Current Plan — {currentTier}

- {hasActive && ( - - )} -
- {planExpiry && ( -

- Renews{' '} - {new Date(planExpiry).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - })} -

- )} -

- {currentTier === 'FREE' - ? 'Free accounts use pay-as-you-go credits. New accounts may receive a one-time signup credit.' - : 'Your subscription includes premium usage each cycle. Top-ups cover any overage.'} -

- {currentPlan && ( -
- {currentPlan.monthlyBudgetUsd > 0 && ( - - Included monthly value: {formatUsdAmount(currentPlan.monthlyBudgetUsd)} - - )} - {currentPlan.weeklyBudgetUsd > 0 && ( - - 7-day cycle budget: {formatUsdAmount(currentPlan.weeklyBudgetUsd)} - - )} - {currentPlan.fiveHourCapUsd > 0 && ( - - 10-hour cap: {formatUsdAmount(currentPlan.fiveHourCapUsd)} - - )} - {currentPlanMeta && currentPlanMeta.discountPercent > 0 && ( - - Premium-usage discount: {currentPlanMeta.discountPercent}% - - )} -
- )} -
- - {/* ── Pay as You Go ── PROMOTED TO TOP ─────────────────── */} - +
+
+ + +
- {/* ── Divider ──────────────────────────────────────────── */} -
-
- - Or subscribe for included usage + discounts - -
-
- - {/* ── Subscription Plans ──────────────────────────────── */} - { paymentConfirmed={paymentConfirmed} onUpgrade={handleUpgrade} /> + )} - {/* ── Inference Budget ────────────────────────────────── */} -
- -
- - {/* ── Auto-Recharge + Payment Methods ────────────────── */} - -
+ )} + + {selectedTab === 'history' && ( + + )}
); diff --git a/app/src/components/settings/panels/__tests__/billingHelpers.test.ts b/app/src/components/settings/panels/__tests__/billingHelpers.test.ts index 243efae2a..5bcc8ea30 100644 --- a/app/src/components/settings/panels/__tests__/billingHelpers.test.ts +++ b/app/src/components/settings/panels/__tests__/billingHelpers.test.ts @@ -34,8 +34,8 @@ describe('PLANS', () => { it('should have BASIC plan aligned with backend config', () => { const basic = PLANS.find(p => p.tier === 'BASIC')!; - expect(basic.monthlyPrice).toBe(20); - expect(basic.annualPrice).toBe(200); + expect(basic.monthlyPrice).toBe(19.99); + expect(basic.annualPrice).toBe(199); expect(basic.discountPercent).toBe(20); expect(basic.monthlyBudgetUsd).toBe(20); expect(basic.weeklyBudgetUsd).toBe(10); @@ -44,11 +44,11 @@ describe('PLANS', () => { it('should have PRO plan aligned with backend config', () => { const pro = PLANS.find(p => p.tier === 'PRO')!; - expect(pro.monthlyPrice).toBe(200); - expect(pro.annualPrice).toBe(2000); + expect(pro.monthlyPrice).toBe(199.99); + expect(pro.annualPrice).toBe(1799.99); expect(pro.discountPercent).toBe(40); - expect(pro.monthlyBudgetUsd).toBe(200); - expect(pro.weeklyBudgetUsd).toBe(100); + expect(pro.monthlyBudgetUsd).toBe(199); + expect(pro.weeklyBudgetUsd).toBe(99); expect(pro.fiveHourCapUsd).toBe(30); }); @@ -109,12 +109,12 @@ describe('displayPrice', () => { expect(displayPrice(freePlan, 'monthly')).toBe('$0'); }); - it('should return $20 for BASIC plan', () => { - expect(displayPrice(basicPlan, 'monthly')).toBe('$20'); + it('should return $19.99 for BASIC plan', () => { + expect(displayPrice(basicPlan, 'monthly')).toBe('$19.99'); }); - it('should return $200 for PRO plan', () => { - expect(displayPrice(proPlan, 'monthly')).toBe('$200'); + it('should return $199.99 for PRO plan', () => { + expect(displayPrice(proPlan, 'monthly')).toBe('$199.99'); }); }); @@ -123,13 +123,12 @@ describe('displayPrice', () => { expect(displayPrice(freePlan, 'annual')).toBe('$0'); }); - it('should return annual equivalent monthly price for BASIC ($200/12 = $17)', () => { + it('should return annual equivalent monthly price for BASIC ($199/12 = $17)', () => { expect(displayPrice(basicPlan, 'annual')).toBe('$17'); }); - it('should return annual equivalent monthly price for PRO ($2000/12 = $167)', () => { - // $2000 / 12 = 166.67, rounded to $167 - expect(displayPrice(proPlan, 'annual')).toBe('$167'); + it('should return annual equivalent monthly price for PRO ($1799.99/12 = $150)', () => { + expect(displayPrice(proPlan, 'annual')).toBe('$150'); }); }); @@ -167,15 +166,15 @@ describe('annualSavings', () => { }); it('should calculate savings for BASIC annual', () => { - // Monthly total: $20 * 12 = $240, Annual: $200 - // Savings: ($240 - $200) / $240 = 16.67%, rounded to 17% + // Monthly total: $19.99 * 12 = $239.88, Annual: $199 + // Savings: ($239.88 - $199) / $239.88 = 17.04%, rounded to 17% expect(annualSavings(basicPlan, 'annual')).toBe(17); }); it('should calculate savings for PRO annual', () => { - // Monthly total: $200 * 12 = $2400, Annual: $2000 - // Savings: ($2400 - $2000) / $2400 = 16.67%, rounded to 17% - expect(annualSavings(proPlan, 'annual')).toBe(17); + // Monthly total: $199.99 * 12 = $2399.88, Annual: $1799.99 + // Savings: ($2399.88 - $1799.99) / $2399.88 = 25.00%, rounded to 25% + expect(annualSavings(proPlan, 'annual')).toBe(25); }); it('should return null when annual price equals monthly * 12 (no savings)', () => { diff --git a/app/src/components/settings/panels/billing/AutoRechargeSection.tsx b/app/src/components/settings/panels/billing/AutoRechargeSection.tsx index b643d12fc..c2d2a4eaa 100644 --- a/app/src/components/settings/panels/billing/AutoRechargeSection.tsx +++ b/app/src/components/settings/panels/billing/AutoRechargeSection.tsx @@ -31,7 +31,6 @@ interface AutoRechargeSectionProps { setArThreshold: (v: number) => void; setArAmount: (v: number) => void; setArWeeklyLimit: (v: number) => void; - setArError: (v: string | null) => void; onArToggle: () => void; onArSave: () => void; // Cards @@ -58,7 +57,6 @@ const AutoRechargeSection = ({ setArThreshold, setArAmount, setArWeeklyLimit, - setArError, onArToggle, onArSave, cards, @@ -75,7 +73,7 @@ const AutoRechargeSection = ({ {/* Header row */}
-

Auto-Recharge Credits

+

Enable Auto-Recharge

Automatically top up when your balance runs low

@@ -116,20 +114,7 @@ const AutoRechargeSection = ({ d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> -

{arError}

- +

{arError}

)} @@ -283,7 +268,7 @@ const AutoRechargeSection = ({
diff --git a/app/src/components/settings/panels/billing/BillingHistoryTab.tsx b/app/src/components/settings/panels/billing/BillingHistoryTab.tsx new file mode 100644 index 000000000..b2488c121 --- /dev/null +++ b/app/src/components/settings/panels/billing/BillingHistoryTab.tsx @@ -0,0 +1,73 @@ +import type { CreditTransaction } from '../../../../services/api/creditsApi'; + +interface BillingHistoryTabProps { + hasActive: boolean; + onManageSubscription: () => void; + transactionRows: CreditTransaction[]; +} + +export default function BillingHistoryTab({ + hasActive, + onManageSubscription, + transactionRows, +}: BillingHistoryTabProps) { + return ( +
+
+

+ Transaction History +

+

+ A quick view of your credit ledger. All credits/debits are shown here. +

+
+ {hasActive && ( + + )} +
+
+
+ {transactionRows.length > 0 ? ( +
+ {transactionRows.map(transaction => { + const isEarn = transaction.type === 'EARN'; + return ( +
+
+

{transaction.action}

+

+ {new Date(transaction.createdAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+
{transaction.type}
+
+ {isEarn ? '+' : '-'}${Math.abs(transaction.amountUsd).toFixed(2)} +
+
+ + Posted + +
+
+ ); + })} +
+ ) : ( +
+ No recent billing activity is available yet. +
+ )} +
+
+ ); +} diff --git a/app/src/components/settings/panels/billing/BillingPaymentsTab.tsx b/app/src/components/settings/panels/billing/BillingPaymentsTab.tsx new file mode 100644 index 000000000..b21229c7f --- /dev/null +++ b/app/src/components/settings/panels/billing/BillingPaymentsTab.tsx @@ -0,0 +1,103 @@ +import type { + AutoRechargeSettings, + CreditBalance, + SavedCard, +} from '../../../../services/api/creditsApi'; +import AutoRechargeSection from './AutoRechargeSection'; +import PayAsYouGoCard from './PayAsYouGoCard'; + +interface BillingPaymentsTabProps { + arAmount: number; + arDirty: boolean; + arError: string | null; + arLoading: boolean; + arSaving: boolean; + arSettings: AutoRechargeSettings | null; + arThreshold: number; + arWeeklyLimit: number; + cards: SavedCard[]; + cardsLoading: boolean; + confirmDeleteId: string | null; + creditBalance: CreditBalance | null; + deletingCardId: string | null; + isLoadingCredits: boolean; + isToppingUp: boolean; + onAddCard: () => void; + onArSave: () => void; + onArToggle: () => void; + onDeleteCard: (paymentMethodId: string) => void; + onSetDefault: (paymentMethodId: string) => void; + onTopUp: (amountUsd: number) => void; + setArAmount: (value: number) => void; + setArThreshold: (value: number) => void; + setArWeeklyLimit: (value: number) => void; + setConfirmDeleteId: (value: string | null) => void; + settingDefaultId: string | null; +} + +export default function BillingPaymentsTab({ + arAmount, + arDirty, + arError, + arLoading, + arSaving, + arSettings, + arThreshold, + arWeeklyLimit, + cards, + cardsLoading, + confirmDeleteId, + creditBalance, + deletingCardId, + isLoadingCredits, + isToppingUp, + onAddCard, + onArSave, + onArToggle, + onDeleteCard, + onSetDefault, + onTopUp, + setArAmount, + setArThreshold, + setArWeeklyLimit, + setConfirmDeleteId, + settingDefaultId, +}: BillingPaymentsTabProps) { + return ( + <> +
+ +
+ + + + ); +} diff --git a/app/src/components/settings/panels/billing/BillingPlansTab.tsx b/app/src/components/settings/panels/billing/BillingPlansTab.tsx new file mode 100644 index 000000000..e6a3f1f3a --- /dev/null +++ b/app/src/components/settings/panels/billing/BillingPlansTab.tsx @@ -0,0 +1,40 @@ +import type { PlanTier } from '../../../../types/api'; +import SubscriptionPlans from './SubscriptionPlans'; + +interface BillingPlansTabProps { + billingInterval: 'monthly' | 'annual'; + currentTier: PlanTier; + isPurchasing: boolean; + onUpgrade: (tier: PlanTier) => void; + paymentConfirmed: boolean; + paymentMethod: 'card' | 'crypto'; + purchasingTier: PlanTier | null; + setBillingInterval: (value: 'monthly' | 'annual') => void; + setPaymentMethod: (value: 'card' | 'crypto') => void; +} + +export default function BillingPlansTab({ + billingInterval, + currentTier, + isPurchasing, + onUpgrade, + paymentConfirmed, + paymentMethod, + purchasingTier, + setBillingInterval, + setPaymentMethod, +}: BillingPlansTabProps) { + return ( + + ); +} diff --git a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx index f45c66300..b18c0615c 100644 --- a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx +++ b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx @@ -1,16 +1,12 @@ -import createDebug from 'debug'; import { useState } from 'react'; -import { type CreditBalance, creditsApi } from '../../../../services/api/creditsApi'; - -const log = createDebug('openhuman:billing-payg'); +import { type CreditBalance } from '../../../../services/api/creditsApi'; interface PayAsYouGoCardProps { creditBalance: CreditBalance | null; isLoadingCredits: boolean; isToppingUp: boolean; onTopUp: (amountUsd: number) => void; - onBalanceRefresh: () => void; } const PayAsYouGoCard = ({ @@ -18,7 +14,6 @@ const PayAsYouGoCard = ({ isLoadingCredits, isToppingUp, onTopUp, - onBalanceRefresh, }: PayAsYouGoCardProps) => { // Backend `GET /payments/credits/balance` returns // { promotionBalanceUsd, teamTopupUsd } @@ -30,155 +25,117 @@ const PayAsYouGoCard = ({ const teamTopupCredits = creditBalance?.teamTopupUsd ?? 0; const availableCredits = promoCredits + teamTopupCredits; - // Coupon state (local — no need to share with other sections) - const [couponCode, setCouponCode] = useState(''); - const [couponLoading, setCouponLoading] = useState(false); - const [couponError, setCouponError] = useState(null); - const [couponSuccess, setCouponSuccess] = useState(null); + const [customTopUpAmount, setCustomTopUpAmount] = useState(''); + const customTopUpAmountValid = Number(customTopUpAmount) > 0; - const handleRedeemCoupon = async () => { - const code = couponCode.trim(); - if (!code || couponLoading) return; - - setCouponLoading(true); - setCouponError(null); - setCouponSuccess(null); - - try { - log('[coupon] redeeming code=%s', code); - const result = await creditsApi.redeemCoupon(code); - setCouponSuccess( - result.pending - ? `Coupon accepted. $${result.amountUsd.toFixed(2)} will be added after the required action.` - : `Coupon redeemed! $${result.amountUsd.toFixed(2)} added to your credits.` - ); - setCouponCode(''); - onBalanceRefresh(); - } catch (err) { - const msg = - err && typeof err === 'object' && 'error' in err - ? String((err as { error: unknown }).error) - : 'Invalid or expired coupon code.'; - log('[coupon] error: %s', msg); - setCouponError(msg); - } finally { - setCouponLoading(false); - } + const handleCustomTopUp = () => { + if (!customTopUpAmountValid || isToppingUp) return; + onTopUp(Number(customTopUpAmount)); }; return ( -
-

Pay as You Go

- - {/* Balance display */} - {creditBalance ? ( -
-
- Available credits - - ${availableCredits.toFixed(2)} - + <> +
+

+ Your Credit Balance +

+

+ You can top up your credits if you ever exhaust your monthly budget or hit rate limits. + Credits are consumed after any included subscription budget is exhausted. +

+ {creditBalance ? ( +
+
+

Available

+

+ ${availableCredits.toFixed(2)} +

+
+
+

Promotional Credits

+

+ ${promoCredits.toFixed(2)} +

+
+
+

Top-up Balance

+

+ ${teamTopupCredits.toFixed(2)} +

+
-
- Signup + promo credits - ${promoCredits.toFixed(2)} -
-
- Team top-up credits - - ${teamTopupCredits.toFixed(2)} - -
-
- ) : isLoadingCredits ? ( -
-
-
-
- ) : ( -

Unable to load balance

- )} - -

- Credits are used after any included subscription budget is consumed. -

- - {/* Top-up buttons */} -
- {[5, 10, 25].map(amount => ( - - ))} -
- - {/* Coupon redemption */} -
-

Have a coupon?

-
- { - setCouponCode(e.target.value.toUpperCase()); - if (couponError) setCouponError(null); - if (couponSuccess) setCouponSuccess(null); - }} - onKeyDown={e => { - if (e.key === 'Enter') handleRedeemCoupon(); - }} - placeholder="XXXX-XXXX" - className="flex-1 px-2.5 py-1.5 text-xs rounded-lg border border-stone-200 bg-stone-50 text-stone-900 placeholder-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500" - /> - -
- - {couponSuccess && ( -
- - - -

{couponSuccess}

-
- )} - - {couponError && ( -
- - - -

{couponError}

+ ) : isLoadingCredits ? ( +
+ {[0, 1, 2].map(index => ( +
+ ))}
+ ) : ( +

Unable to load balance.

)}
-
+
+

+ Choose a Top-up Amount +

+

+ Choose one of the preset amounts above or enter your own charge amount. +

+ +
+ {[5, 10, 25].map(amount => ( + + ))} +
+ +
+
+
+ +
+ $ + setCustomTopUpAmount(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleCustomTopUp(); + }} + placeholder="Enter amount" + className="w-full border-0 bg-transparent px-3 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-0" + /> +
+

+ Choose one of the preset amounts above or enter your own charge amount. +

+
+ +
+
+
+ ); }; diff --git a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx index f418caa89..536c55d8d 100644 --- a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx +++ b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx @@ -25,212 +25,231 @@ const SubscriptionPlans = ({ onUpgrade, }: SubscriptionPlansProps) => ( <> - {/* Interval toggle */} -
- - +
+

+ Choose a Subscription Plan +

+

+ Compare plans, switch billing cadence, and choose a payment method. +

+ +
+
+

Pay using crypto?

+

+ You can optionally choose to pay annually using BTC/ETH/USDC. +

+
+ +
- {/* Plan tier cards */} -
- {PLANS.map(plan => { - const isCurrent = plan.tier === currentTier; - const isUpgrade = checkIsUpgrade(plan.tier, currentTier); - const savings = annualSavings(plan, billingInterval); - const isThisPurchasing = isPurchasing && purchasingTier === plan.tier; - - return ( -
+
+
+ + +
+
- {/* Header: name + tagline on left, price on right */} -
-
-

{plan.name}

- {plan.tagline &&

{plan.tagline}

} -
-
-
- - {displayPrice(plan, billingInterval)} - - {plan.tier !== 'FREE' && /mo} -
- {plan.tier !== 'FREE' && billingInterval === 'annual' && ( -

billed ${plan.annualPrice}/yr

- )} - {savings && ( - - Save {savings}% - - )} -
-
+ {paymentConfirmed && ( +
+
+ + + +

+ Payment confirmed! Your plan has been updated. +

+
+
+ )} - {/* Divider */} -
+ {isPurchasing && ( +
+
+ + + + +

+ Waiting for payment confirmation... Complete checkout in the browser window that + opened. +

+
+
+ )} - {/* Feature list */} -
    - {plan.features.map(f => ( -
  • - {f.included ? ( - +
    + {PLANS.map(plan => { + const isCurrent = plan.tier === currentTier; + const isUpgrade = checkIsUpgrade(plan.tier, currentTier); + const savings = annualSavings(plan, billingInterval); + const isThisPurchasing = isPurchasing && purchasingTier === plan.tier; + const isPopular = plan.recommended && billingInterval === 'annual'; + + return ( +
    +
    +
    + {plan.tier === 'PRO' ? ( + + + + ) : plan.tier === 'BASIC' ? ( + ) : ( - + )} - - {f.text} - -
  • - ))} -
- - {/* CTA */} -
- {isCurrent ? ( -
- Current Plan
- ) : isUpgrade ? ( - - ) : null} + +
+
+

+ {plan.name} +

+ {isPopular && ( + + Popular + + )} + {isCurrent && !plan.recommended && ( + + Current + + )} +
+
+ {plan.features.slice(0, 4).map(feature => ( + + {feature.text} + + ))} +
+
+
+ +
+
+

+ {displayPrice(plan, billingInterval)} + {plan.tier !== 'FREE' && ( + /mo + )} +

+ {plan.tier !== 'FREE' && billingInterval === 'annual' && ( +

Billed ${plan.annualPrice}/yr

+ )} + {savings && ( +

+ Save {savings}% +

+ )} +
+ + {isCurrent ? ( +
+ Current Plan +
+ ) : isUpgrade ? ( + + ) : null} +
-
- ); - })} -
- - {/* Payment confirmed banner */} - {paymentConfirmed && ( -
-
- - - -

- Payment confirmed! Your plan has been updated. -

-
+ ); + })}
- )} - - {/* Purchasing overlay message */} - {isPurchasing && ( -
-
- - - - -

- Waiting for payment confirmation... Complete checkout in the browser window that opened. -

-
-
- )} - - {/* Pay with crypto toggle */} -
-
-

Pay with Crypto

-

- You can choose to pay annually using crypto -

-
-
); diff --git a/app/src/components/settings/panels/billingHelpers.ts b/app/src/components/settings/panels/billingHelpers.ts index e06d4a929..f08ca30de 100644 --- a/app/src/components/settings/panels/billingHelpers.ts +++ b/app/src/components/settings/panels/billingHelpers.ts @@ -32,17 +32,17 @@ export const PLANS: PlanMeta[] = [ discountPercent: 0, tagline: 'Get started at no cost', features: [ - { text: 'Access to all integrations and inference', included: true }, - { text: 'One-time signup credits when available', included: true }, - { text: 'Pay-as-you-go top-ups', included: true }, - { text: 'No subscription discount', included: false }, + { text: 'Access to Everything', included: true }, + { text: 'Heavy Rate Limits', included: true }, + { text: 'Pay-as-you-go', included: true }, + { text: 'No discounts', included: false }, ], }, { tier: 'BASIC', name: 'Basic', - monthlyPrice: 20, - annualPrice: 200, + monthlyPrice: 19.99, + annualPrice: 199, monthlyBudgetUsd: 20, weeklyBudgetUsd: 10, fiveHourCapUsd: 3, @@ -50,25 +50,25 @@ export const PLANS: PlanMeta[] = [ recommended: true, tagline: 'Best value for most users', features: [ - { text: '$20/mo in premium usage included', included: true }, - { text: '20% discount on all premium usage', included: true }, - { text: 'Pay-as-you-go top-ups for overflow', included: true }, + { text: 'Everything in Free', included: true }, + { text: '20x more usage', included: true }, + { text: 'Cloud features enabled', included: true }, ], }, { tier: 'PRO', name: 'Pro', - monthlyPrice: 200, - annualPrice: 2000, - monthlyBudgetUsd: 200, - weeklyBudgetUsd: 100, + monthlyPrice: 199.99, + annualPrice: 1799.99, + monthlyBudgetUsd: 199, + weeklyBudgetUsd: 99, fiveHourCapUsd: 30, discountPercent: 40, tagline: 'For power users and teams', features: [ - { text: '$200/mo in premium usage included', included: true }, - { text: '40% discount on all premium usage', included: true }, - { text: 'Best fit for heavy bandwidth and agents', included: true }, + { text: 'Everything in Basic', included: true }, + { text: '40x more usage', included: true }, + { text: 'Higher Rate Limits', included: true }, ], }, ]; diff --git a/app/src/components/skills/SkillCategoryFilter.tsx b/app/src/components/skills/SkillCategoryFilter.tsx index 590b9b371..58bee7634 100644 --- a/app/src/components/skills/SkillCategoryFilter.tsx +++ b/app/src/components/skills/SkillCategoryFilter.tsx @@ -1,13 +1,10 @@ -export type SkillCategory = - | 'All' - | 'Built-in' - | 'Channels' - | 'Productivity' - | 'Chat' - | 'Tools & Automation' - | 'Social' - | 'Platform' - | 'Other'; +import PillTabBar from '../PillTabBar'; +import type { SkillCategory } from './skillCategories'; +import { + skillCategoryChipClassName, + SkillCategoryIcon, + skillCategoryIconClassName, +} from './skillIcons'; interface SkillCategoryFilterProps { categories: SkillCategory[]; @@ -15,28 +12,27 @@ interface SkillCategoryFilterProps { onChange: (category: SkillCategory) => void; } -const SkillCategoryFilter = ({ - categories, - selected, - onChange, -}: SkillCategoryFilterProps) => { +const SkillCategoryFilter = ({ categories, selected, onChange }: SkillCategoryFilterProps) => { return ( -
- {categories.map(cat => ( - - ))} -
+ ({ label: category, value: category }))} + selected={selected} + onChange={onChange} + renderItem={(item, active) => ( + + + + + {item.label} + + )} + /> ); }; diff --git a/app/src/components/skills/skillCategories.ts b/app/src/components/skills/skillCategories.ts new file mode 100644 index 000000000..865381736 --- /dev/null +++ b/app/src/components/skills/skillCategories.ts @@ -0,0 +1,22 @@ +export type SkillCategory = + | 'All' + | 'Built-in' + | 'Channels' + | 'Productivity' + | 'Chat' + | 'Tools & Automation' + | 'Social' + | 'Platform' + | 'Other'; + +export const SKILL_CATEGORY_ORDER: SkillCategory[] = [ + 'All', + 'Built-in', + 'Channels', + 'Chat', + 'Productivity', + 'Tools & Automation', + 'Social', + 'Platform', + 'Other', +]; diff --git a/app/src/components/skills/skillIcons.tsx b/app/src/components/skills/skillIcons.tsx new file mode 100644 index 000000000..7f76be9d8 --- /dev/null +++ b/app/src/components/skills/skillIcons.tsx @@ -0,0 +1,164 @@ +import type { ReactNode } from 'react'; +import type { IconType } from 'react-icons'; +import { FaDiscord, FaGlobe, FaTelegramPlane } from 'react-icons/fa'; +import { + LuBlocks, + LuBot, + LuKeyboard, + LuMessageSquareMore, + LuMic, + LuMonitor, + LuPlugZap, + LuShare2, + LuSparkles, + LuWrench, +} from 'react-icons/lu'; + +import type { SkillCategory } from './skillCategories'; + +function iconClasses(...parts: Array): string { + return parts.filter(Boolean).join(' '); +} + +export function SkillIconBadge({ + icon: Icon, + label, + bgClassName, + iconClassName, + className, +}: { + icon: IconType; + label: string; + bgClassName: string; + iconClassName: string; + className?: string; +}) { + return ( + + + ); +} + +export const CHANNEL_ICONS: Record = { + telegram: ( + + ), + discord: ( + + ), + web: ( + + ), +}; + +const CATEGORY_META: Record< + SkillCategory, + { icon: IconType; chipClassName: string; iconClassName: string; headingClassName: string } +> = { + All: { + icon: LuBlocks, + chipClassName: 'bg-stone-100 text-stone-600', + iconClassName: 'text-stone-500', + headingClassName: 'text-stone-500', + }, + 'Built-in': { + icon: LuSparkles, + chipClassName: 'bg-primary-50 text-primary-700', + iconClassName: 'text-primary-600', + headingClassName: 'text-primary-600', + }, + Channels: { + icon: LuMessageSquareMore, + chipClassName: 'bg-sky-50 text-sky-700', + iconClassName: 'text-sky-600', + headingClassName: 'text-sky-600', + }, + Productivity: { + icon: LuBot, + chipClassName: 'bg-emerald-50 text-emerald-700', + iconClassName: 'text-emerald-600', + headingClassName: 'text-emerald-600', + }, + Chat: { + icon: LuShare2, + chipClassName: 'bg-violet-50 text-violet-700', + iconClassName: 'text-violet-600', + headingClassName: 'text-violet-600', + }, + 'Tools & Automation': { + icon: LuWrench, + chipClassName: 'bg-amber-50 text-amber-700', + iconClassName: 'text-amber-600', + headingClassName: 'text-amber-600', + }, + Social: { + icon: LuPlugZap, + chipClassName: 'bg-rose-50 text-rose-700', + iconClassName: 'text-rose-600', + headingClassName: 'text-rose-600', + }, + Platform: { + icon: LuShare2, + chipClassName: 'bg-cyan-50 text-cyan-700', + iconClassName: 'text-cyan-600', + headingClassName: 'text-cyan-600', + }, + Other: { + icon: LuBlocks, + chipClassName: 'bg-stone-100 text-stone-700', + iconClassName: 'text-stone-500', + headingClassName: 'text-stone-500', + }, +}; + +export function SkillCategoryIcon({ + category, + className, +}: { + category: SkillCategory; + className?: string; +}) { + const Icon = CATEGORY_META[category].icon; + return
)} - {teamUsage && - ((teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0) || - (!teamUsage.bypassCycleLimit && - teamUsage.fiveHourCapUsd > 0 && - teamUsage.cycleLimit5hr >= teamUsage.fiveHourCapUsd)) && ( -
-
- - - -

- {teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0 + {teamUsage && (shouldShowBudgetCompletedMessage || isRateLimited) && ( +

+
+ + + +

+ {shouldShowBudgetCompletedMessage + ? teamUsage.cycleBudgetUsd > 0 ? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.` - : `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`} -

-
- {teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0 && ( - - )} + : 'Your included budget is complete. Add credits or upgrade to continue.' + : `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`} +

- )} + {shouldShowBudgetCompletedMessage && ( + + )} +
+ )}
{(isLoadingBudget || teamUsage) && ( diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 800c9545c..b9f38024b 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { ActionableCard } from '../components/intelligence/ActionableCard'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; +import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab'; +import IntelligenceMemoryTab from '../components/intelligence/IntelligenceMemoryTab'; +import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import { ToastContainer } from '../components/intelligence/Toast'; import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils'; +import PillTabBar from '../components/PillTabBar'; import { useConsciousItems } from '../hooks/useConsciousItems'; import { useSnoozeActionableItem, @@ -27,15 +29,7 @@ import type { type IntelligenceTab = 'memory' | 'subconscious' | 'dreams'; -const SKILL_KEYWORDS = - /\bskill\b|\boauth\b|\bnotion\b|\bgmail\b|\bintegration\b|\bdisconnect|\breconnect|\bre-?auth/i; - -function isSkillRelated(title: string, description: string): boolean { - return SKILL_KEYWORDS.test(title) || SKILL_KEYWORDS.test(description); -} - export default function Intelligence() { - const navigate = useNavigate(); const { aiStatus } = useIntelligenceStats(); const [activeTab, setActiveTab] = useState('memory'); @@ -273,7 +267,32 @@ export default function Intelligence() { return (
-
+
+ ({ label: tab.label, value: tab.id }))} + selected={activeTab} + onChange={setActiveTab} + activeClassName="border-primary-600 bg-primary-600 text-white" + renderItem={(item, active) => { + const tab = tabs.find(entry => entry.id === item.value); + return ( + + {item.label} + {tab?.comingSoon && ( + + Soon + + )} + + ); + }} + /> +
{/* Header */} @@ -320,546 +339,47 @@ export default function Intelligence() {
- {/* Tabs */} -
- {tabs.map(tab => ( - - ))} -
- {/* Tab content */} {activeTab === 'memory' && ( - <> - {/* Filters */} -
-
- setSearchFilter(e.target.value)} - className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors" - /> -
- -
- - {/* Content */} - {itemsLoading && !usingMemoryData ? ( -
-
-
-
-

- Loading Intelligence... -

-

Fetching your actionable items

-
- ) : isRunning && items.length === 0 ? ( -
-
-
-
-

- Analyzing your data… -

-

- The conscious loop is reviewing your connected skills -

-
- ) : timeGroups.length === 0 ? ( -
-
- - - -
- {searchFilter || sourceFilter !== 'all' ? ( - <> -

No matches

-

- No items match your current filters. -

- - ) : usingMemoryData ? ( - <> -

- All caught up! -

-

No actionable items at the moment.

- - ) : ( - <> -

- No analysis yet -

-

- Run an analysis to extract actionable items from your connected skills. -

- - - )} -
- ) : ( -
- {isRunning && ( -
-
- Analyzing your data… -
- )} - {timeGroups.map((group, groupIndex) => ( -
-
-

- {group.label} -

-
- {group.count} -
-
-
- {group.items.map((item, itemIndex) => ( -
- -
- ))} -
-
- ))} -
- )} - + )} {activeTab === 'subconscious' && ( -
- {/* Status bar */} -
-
- {subconsciousEngineStatus && ( - <> - {subconsciousEngineStatus.task_count} tasks - | - {subconsciousEngineStatus.total_ticks} ticks - {subconsciousEngineStatus.last_tick_at && ( - <> - | - - Last:{' '} - {new Date( - subconsciousEngineStatus.last_tick_at * 1000 - ).toLocaleTimeString()} - - - )} - {subconsciousEngineStatus.consecutive_failures > 0 && ( - <> - | - - {subconsciousEngineStatus.consecutive_failures} failed - - - )} - - )} -
-
- {/* Interval selector */} -
- - - - -
- -
-
- - {/* Escalations — needs user input */} - {escalations.length > 0 && ( -
-

- - Approval Needed - - {escalations.length} - -

-
- {escalations.map(esc => ( -
-
-
-

{esc.title}

-

{esc.description}

-
- - {esc.priority} - - - Requires your approval to proceed - -
-
-
- {isSkillRelated(esc.title, esc.description) ? ( - - ) : ( - - )} - -
-
-
- ))} -
-
- )} - - {/* Active tasks */} -
-

Active Tasks

- {subconsciousLoading && subconsciousTasks.length === 0 ? ( -
-
-
- ) : subconsciousTasks.filter(t => !t.completed).length === 0 ? ( -

No active tasks. Add one below.

- ) : ( -
- {/* System tasks — always-on, no controls */} - {subconsciousTasks - .filter(t => !t.completed && t.source === 'system') - .map(task => ( -
-
- - {task.title} - - - default - -
- ))} - {/* User tasks — toggle switch + delete */} - {subconsciousTasks - .filter(t => !t.completed && t.source !== 'system') - .map(task => ( -
-
- - - {task.title} - -
- -
- ))} -
- )} - - {/* Add task */} -
{ - e.preventDefault(); - const title = newTaskTitle.trim(); - if (!title) return; - try { - await addSubconsciousTask(title); - setNewTaskTitle(''); - } catch { - // handled by hook - } - }} - className="flex gap-2 mt-3"> - setNewTaskTitle(e.target.value)} - className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors" - /> - -
-
- - {/* Execution log */} -
-

Activity Log

- {logEntries.length === 0 ? ( -

- No activity yet. Run a tick to see results. -

- ) : ( -
- {logEntries.map(entry => ( -
- - {new Date(entry.tick_at * 1000).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - - 120 ? 'cursor-pointer hover:text-stone-900' : ''}`} - onClick={() => { - if (entry.result && entry.result.length > 120) { - setExpandedLogIds(prev => { - const next = new Set(prev); - if (next.has(entry.id)) next.delete(entry.id); - else next.add(entry.id); - return next; - }); - } - }}> - {entry.result - ? expandedLogIds.has(entry.id) - ? entry.result - : entry.result.length > 120 - ? `${entry.result.substring(0, 120)}...` - : entry.result - : entry.decision === 'noop' - ? 'Nothing new' - : entry.decision === 'act' - ? 'Completed' - : entry.decision === 'in_progress' - ? 'Evaluating...' - : entry.decision === 'escalate' - ? 'Waiting for approval' - : entry.decision === 'failed' - ? 'Failed' - : entry.decision === 'cancelled' - ? 'Cancelled' - : entry.decision === 'dismissed' - ? 'Skipped' - : entry.decision} - - {entry.duration_ms != null && ( - - {entry.duration_ms > 1000 - ? `${(entry.duration_ms / 1000).toFixed(1)}s` - : `${entry.duration_ms}ms`} - - )} -
- ))} -
- )} -
-
+ )} - {activeTab === 'dreams' && ( -
-
- - - - -
-

Dreams

-

- Twice everyday, OpenHuman will generate a dream (or a summary) based on everything - that has happened in your life today. These dreams re then indexed and can be used - to influence OpenHuman's behavior. -

-

Coming soon

-
- )} + {activeTab === 'dreams' && }
diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx index 12aabe54c..614f67319 100644 --- a/app/src/pages/Rewards.tsx +++ b/app/src/pages/Rewards.tsx @@ -1,55 +1,16 @@ import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import ReferralRewardsSection from '../components/referral/ReferralRewardsSection'; -import RewardsCouponSection from '../components/rewards/RewardsCouponSection'; -import { useUser } from '../hooks/useUser'; +import PillTabBar from '../components/PillTabBar'; +import RewardsCommunityTab from '../components/rewards/RewardsCommunityTab'; +import RewardsRedeemTab from '../components/rewards/RewardsRedeemTab'; +import RewardsReferralsTab from '../components/rewards/RewardsReferralsTab'; import { rewardsApi } from '../services/api/rewardsApi'; -import type { - RewardsAchievement, - RewardsDiscordRoleStatus, - RewardsSnapshot, -} from '../types/rewards'; -import { DISCORD_INVITE_URL } from '../utils/links'; +import type { RewardsSnapshot } from '../types/rewards'; -function discordMembershipLabel(snapshot: RewardsSnapshot | null): string { - if (!snapshot) return 'Waiting for backend sync'; - switch (snapshot.discord.membershipStatus) { - case 'member': - return 'Joined the server'; - case 'not_in_guild': - return 'Linked, but not in server'; - case 'not_linked': - return 'Not linked'; - default: - return 'Membership status unavailable'; - } -} - -function roleStatusLabel(status: RewardsDiscordRoleStatus): string { - switch (status) { - case 'assigned': - return 'Assigned in Discord'; - case 'not_assigned': - return 'Earned, pending Discord assignment'; - case 'not_linked': - return 'Link Discord to receive this role'; - case 'not_in_guild': - return 'Join the server to receive this role'; - case 'not_configured': - return 'Discord role not configured'; - default: - return 'Role sync status unavailable'; - } -} - -function formatNumber(value: number): string { - return new Intl.NumberFormat('en-US').format(Math.max(0, Math.trunc(value))); -} +type RewardsTab = 'referrals' | 'redeem' | 'rewards'; const Rewards = () => { - const navigate = useNavigate(); - const { user } = useUser(); + const [selectedTab, setSelectedTab] = useState('rewards'); const [snapshot, setSnapshot] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -93,221 +54,27 @@ const Rewards = () => { }; }, []); - const rewardRoles: RewardsAchievement[] = snapshot?.achievements ?? []; - const unlocked = - snapshot?.summary.unlockedCount ?? rewardRoles.filter(role => role.unlocked).length; - const total = snapshot?.summary.totalCount ?? rewardRoles.length; - const inviteUrl = snapshot?.discord.inviteUrl ?? DISCORD_INVITE_URL; - const progressWidth = total > 0 ? (unlocked / total) * 100 : 0; - return ( -
-
- - +
+
+ -
-
-
-
- Discord Rewards -
-

Earn community roles

-

- Join the OpenHuman Discord, connect your account, and track backend-synced rewards - and role assignments from one place. -

-
- -
- - -
-
-
- - {error ? ( -
- Rewards sync is unavailable right now. The page is showing connection guidance without - claiming new unlocks. Details: {error} -
- ) : null} - -
-
-
- Progress -
-
- {isLoading ? '...' : `${unlocked}/${total}`} -
-

- {snapshot - ? 'Server-tracked achievements and Discord reward state.' - : isLoading - ? 'Loading rewards from the backend.' - : 'Waiting for backend rewards data.'} -

- -
-
-
- -
-
- Discord linked - - {snapshot ? (snapshot.discord.linked ? 'Yes' : 'No') : 'Unknown'} - -
-
- Discord server - {discordMembershipLabel(snapshot)} -
-
- Plan - - {snapshot?.summary.plan ?? user?.subscription?.plan ?? 'FREE'} - -
-
- Current streak - - {snapshot ? `${snapshot.metrics.currentStreakDays} days` : 'Unknown'} - -
-
- Cumulative tokens - - {snapshot ? formatNumber(snapshot.metrics.cumulativeTokens) : 'Unknown'} - -
-
-
- -
- {isLoading ? ( -
-
Loading rewards…
-
- ) : rewardRoles.length > 0 ? ( - rewardRoles.map(role => ( -
-
-
-
-

{role.title}

- - {role.unlocked ? 'Unlocked' : 'Locked'} - -
-

{role.description}

-
- -
- {role.unlocked ? ( - - - - ) : ( - - - - )} -
-
- -
-
-
- Unlock Action -
-
{role.actionLabel}
-
-
-
- Server Progress -
-
- {role.progressLabel} -
-
-
-
- Discord Role -
-
- {roleStatusLabel(role.discordRoleStatus)} -
-
-
-
- Credit Reward -
-
- {role.creditAmountUsd != null ? `$${role.creditAmountUsd}` : 'None'} -
-
-
-
- )) - ) : ( -
-

Rewards sync pending

-

- The backend did not return achievement data yet. Join Discord and connect your - account now, then refresh this page once sync is available again. -

-
- )} -
-
+ {selectedTab === 'referrals' ? ( + + ) : selectedTab === 'redeem' ? ( + + ) : ( + + )}
); diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 98cb02b92..ad7a1a89a 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; import AgentChatPanel from '../components/settings/panels/AgentChatPanel'; @@ -78,22 +79,6 @@ const accountSettingsItems = [ ), }, - { - id: 'billing', - title: 'Billing & Usage', - description: 'Subscription plan, pay-as-you-go credits, and payment methods', - route: 'billing', - icon: ( - - - - ), - }, { id: 'privacy', title: 'Privacy', @@ -220,82 +205,108 @@ const aiModelsSettingsItems = [ }, ]; -const Settings = () => { +const WrappedSettingsPage = ({ children }: { children: ReactNode }) => { return (
- - } /> - - } - /> - - } - /> - - } - /> - {/* Account & Billing leaf panels */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Features leaf panels */} - } /> - } /> - } /> - } /> - } /> - {/* AI & Models leaf panels */} - } /> - {/* Developer Options */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Fallback */} - } /> - -
- Beta build - v{APP_VERSION} -
+ {children}
); }; +function wrapSettingsPage(element: ReactNode) { + return ( + + {element} +
+ Beta build - v{APP_VERSION} +
+
+ ); +} + +const Settings = () => { + return ( +
+ + )} /> + + )} + /> + + )} + /> + + )} + /> + {/* Account & Billing leaf panels */} + )} /> + )} /> + )} /> + )} + /> + )} + /> + )} /> + )} /> + )} /> + {/* BillingPanel intentionally uses its own wider layout. */} + } /> + )} /> + {/* Features leaf panels */} + )} /> + )} /> + )} /> + )} /> + )} /> + {/* AI & Models leaf panels */} + )} /> + {/* Developer Options */} + )} /> + )} /> + )} /> + )} /> + )} + /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + {/* Fallback */} + } /> + +
+ ); +}; + export default Settings; diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index b0b023fb7..477d8ead6 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import ChannelSetupModal from '../components/channels/ChannelSetupModal'; import ComposioConnectModal from '../components/composio/ComposioConnectModal'; @@ -11,7 +11,14 @@ import { import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal'; import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal'; import UnifiedSkillCard from '../components/skills/SkillCard'; -import SkillCategoryFilter, { type SkillCategory } from '../components/skills/SkillCategoryFilter'; +import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories'; +import SkillCategoryFilter from '../components/skills/SkillCategoryFilter'; +import { + BUILT_IN_SKILL_ICONS, + CHANNEL_ICONS, + skillCategoryHeadingClassName, + SkillCategoryIcon, +} from '../components/skills/skillIcons'; import SkillSearchBar from '../components/skills/SkillSearchBar'; import VoiceSetupModal from '../components/skills/VoiceSetupModal'; import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocompleteSkillStatus'; @@ -19,15 +26,11 @@ import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligenc import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus'; import { useChannelDefinitions } from '../hooks/useChannelDefinitions'; import { useComposioIntegrations } from '../lib/composio/hooks'; +import { canonicalizeComposioToolkitSlug } from '../lib/composio/toolkitSlug'; import { type ComposioConnection, deriveComposioState } from '../lib/composio/types'; import { useAppSelector } from '../store/hooks'; import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels'; - -const CHANNEL_ICONS: Record = { - telegram: '\u2708\uFE0F', - discord: '\uD83C\uDFAE', - web: '\uD83C\uDF10', -}; +import { subconsciousEscalationsDismiss } from '../utils/tauriCommands'; function channelStatusDot(status: ChannelConnectionStatus): string { switch (status) { @@ -120,16 +123,7 @@ const BUILT_IN_SKILLS = [ description: 'Capture windows, summarize what is on screen, and feed useful context into memory.', route: '/settings/screen-intelligence', - icon: ( - - - - ), + icon: BUILT_IN_SKILL_ICONS.screenIntelligence, }, { id: 'text-autocomplete', @@ -137,32 +131,14 @@ const BUILT_IN_SKILLS = [ description: 'Suggest inline completions while you type and control where autocomplete is active.', route: '/settings/autocomplete', - icon: ( - - - - ), + icon: BUILT_IN_SKILL_ICONS.textAutocomplete, }, { id: 'voice-stt', title: 'Voice Intelligence', description: 'Use the microphone for dictation and voice-driven chat with your AI.', route: '/settings/voice', - icon: ( - - - - ), + icon: BUILT_IN_SKILL_ICONS.voiceStt, }, ]; @@ -188,6 +164,7 @@ interface SkillItem { // ─── Main Skills Page ────────────────────────────────────────────────────────── export default function Skills() { + const location = useLocation(); const navigate = useNavigate(); const { definitions: channelDefs } = useChannelDefinitions(); const channelConnections = useAppSelector(state => state.channelConnections); @@ -212,6 +189,43 @@ export default function Skills() { const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('All'); + const pendingEscalationId = + location.state && + typeof location.state === 'object' && + 'subconsciousEscalationId' in location.state && + typeof location.state.subconsciousEscalationId === 'string' + ? location.state.subconsciousEscalationId + : null; + + const clearPendingEscalationState = useCallback(() => { + navigate(location.pathname, { replace: true, state: null }); + }, [location.pathname, navigate]); + + const dismissPendingEscalationIfResolved = useCallback( + async (resolution: string) => { + if (!pendingEscalationId) return; + console.debug('[skills][subconscious] dismiss escalation:start', { + escalationId: pendingEscalationId, + resolution, + }); + try { + await subconsciousEscalationsDismiss(pendingEscalationId); + console.debug('[skills][subconscious] dismiss escalation:success', { + escalationId: pendingEscalationId, + resolution, + }); + } catch (error) { + console.debug('[skills][subconscious] dismiss escalation:error', { + escalationId: pendingEscalationId, + resolution, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + clearPendingEscalationState(); + }, + [clearPendingEscalationState, pendingEscalationId] + ); useEffect(() => { if (!import.meta.env.DEV) return; @@ -239,7 +253,7 @@ export default function Skills() { ); const composioCatalogToolkits = useMemo(() => { - const normalizedToolkits = composioToolkits.map(slug => slug.toLowerCase()); + const normalizedToolkits = composioToolkits.map(slug => canonicalizeComposioToolkitSlug(slug)); const missingKnownToolkits = KNOWN_COMPOSIO_TOOLKITS.filter( slug => !normalizedToolkits.includes(slug) ); @@ -281,7 +295,7 @@ export default function Skills() { kind: 'channel', channelDef: def, channelStatus: bestChannelStatus(def.id as ChannelType), - icon: {CHANNEL_ICONS[def.icon] ?? ''}, + icon: CHANNEL_ICONS[def.icon], }); } @@ -318,18 +332,7 @@ export default function Skills() { for (const item of allItems) { cats.add(item.category); } - const order: SkillCategory[] = [ - 'All', - 'Built-in', - 'Channels', - 'Chat', - 'Productivity', - 'Tools & Automation', - 'Social', - 'Platform', - 'Other', - ]; - return order.filter(c => cats.has(c)); + return SKILL_CATEGORY_ORDER.filter(c => cats.has(c)); }, [allItems]); const filteredItems = useMemo(() => { @@ -397,7 +400,15 @@ export default function Skills() { key={category} className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
-

{category}

+

+ + + + {category} +

{items.map(item => { @@ -593,7 +604,10 @@ export default function Skills() { void refreshComposio()} + onChanged={() => { + void refreshComposio(); + void dismissPendingEscalationIfResolved(`composio:${composioModalToolkit.slug}`); + }} onClose={() => setComposioModalToolkit(null)} /> )} diff --git a/app/src/pages/__tests__/Rewards.test.tsx b/app/src/pages/__tests__/Rewards.test.tsx index f6201f212..fa9611c8b 100644 --- a/app/src/pages/__tests__/Rewards.test.tsx +++ b/app/src/pages/__tests__/Rewards.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -6,11 +6,11 @@ import Rewards from '../Rewards'; const { rewardsApi } = vi.hoisted(() => ({ rewardsApi: { getMyRewards: vi.fn() } })); -vi.mock('../../components/referral/ReferralRewardsSection', () => ({ +vi.mock('../../components/rewards/RewardsReferralsTab', () => ({ default: () =>
Referral Rewards Section
, })); -vi.mock('../../components/rewards/RewardsCouponSection', () => ({ +vi.mock('../../components/rewards/RewardsRedeemTab', () => ({ default: () =>
Rewards Coupon Section
, })); @@ -70,14 +70,14 @@ describe('Rewards page', () => { ); - expect(screen.getByText('Loading rewards…')).toBeInTheDocument(); + expect(screen.queryAllByText('Loading rewards…').length).toBeGreaterThan(0); await waitFor(() => { expect(screen.getByText('7-Day Streak')).toBeInTheDocument(); }); - expect(screen.getByText('Assigned in Discord')).toBeInTheDocument(); - expect(screen.getByText('1/2')).toBeInTheDocument(); + expect(screen.getByText('Joined the server')).toBeInTheDocument(); + expect(screen.getByText('1 of 2 achievements unlocked')).toBeInTheDocument(); }); it('shows a conservative error state when rewards fail to load', async () => { @@ -96,4 +96,83 @@ describe('Rewards page', () => { expect(screen.getByText('Rewards sync pending')).toBeInTheDocument(); expect(screen.queryByText('Unlocked')).not.toBeInTheDocument(); }); + + it('switches to the referrals tab content', async () => { + rewardsApi.getMyRewards.mockResolvedValueOnce({ + discord: { + linked: false, + discordId: null, + inviteUrl: 'https://discord.gg/openhuman', + membershipStatus: 'not_linked', + }, + summary: { + unlockedCount: 0, + totalCount: 0, + assignedDiscordRoleCount: 0, + plan: 'FREE', + hasActiveSubscription: false, + }, + metrics: { + currentStreakDays: 0, + longestStreakDays: 0, + cumulativeTokens: 0, + featuresUsedCount: 0, + trackedFeaturesCount: 0, + lastEvaluatedAt: '2026-04-09T00:00:00.000Z', + lastSyncedAt: '2026-04-09T01:00:00.000Z', + }, + achievements: [], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Referrals' })); + + expect(screen.getByText('Referral Rewards Section')).toBeInTheDocument(); + expect(screen.queryByText('Rewards Coupon Section')).not.toBeInTheDocument(); + expect(screen.queryByText('Earn community roles')).not.toBeInTheDocument(); + }); + + it('switches to the redeem tab content', async () => { + rewardsApi.getMyRewards.mockResolvedValueOnce({ + discord: { + linked: false, + discordId: null, + inviteUrl: 'https://discord.gg/openhuman', + membershipStatus: 'not_linked', + }, + summary: { + unlockedCount: 0, + totalCount: 0, + assignedDiscordRoleCount: 0, + plan: 'FREE', + hasActiveSubscription: false, + }, + metrics: { + currentStreakDays: 0, + longestStreakDays: 0, + cumulativeTokens: 0, + featuresUsedCount: 0, + trackedFeaturesCount: 0, + lastEvaluatedAt: '2026-04-09T00:00:00.000Z', + lastSyncedAt: '2026-04-09T01:00:00.000Z', + }, + achievements: [], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Redeem' })); + + expect(screen.getByText('Rewards Coupon Section')).toBeInTheDocument(); + expect(screen.queryByText('Referral Rewards Section')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx index c496380d9..a601861f1 100644 --- a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx @@ -43,6 +43,6 @@ describe('Skills page — Notion composio integration', () => { fireEvent.click(within(notionCard as HTMLElement).getByRole('button', { name: 'Connect' })); expect(await screen.findByRole('heading', { name: 'Connect Notion' })).toBeInTheDocument(); - expect(screen.getByText(/Connect your Notion account through Composio\./i)).toBeInTheDocument(); + expect(screen.getByText(/Connect your Notion account\./i)).toBeInTheDocument(); }); }); diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index d14981583..9d9e328ed 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -53,14 +53,14 @@ interface Stage { const STAGES: Stage[] = [ { id: 'gmail-search', - label: 'Searching your emails', + label: 'Indexing your GMail', doneSignal: 'Found LinkedIn profile', errorSignal: 'Gmail search failed', skipSignal: 'No LinkedIn profile URL', }, { id: 'apify-scrape', - label: 'Scraping your LinkedIn', + label: 'Finding your LinkedIn', doneSignal: 'profile scraped successfully', errorSignal: 'scrape failed', }, diff --git a/app/src/services/api/__tests__/billingApi.test.ts b/app/src/services/api/__tests__/billingApi.test.ts index 8d9b4274c..9dc8dfd3f 100644 --- a/app/src/services/api/__tests__/billingApi.test.ts +++ b/app/src/services/api/__tests__/billingApi.test.ts @@ -214,4 +214,22 @@ describe('creditsApi.getBalance', () => { expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_balance'); expect(result).toEqual({ promotionBalanceUsd: 0, teamTopupUsd: 3 }); }); + + it('accepts snake_case balance fields from the backend', async () => { + mockCallCoreCommand.mockResolvedValue({ promotion_balance_usd: '12.5', team_topup_usd: 4 }); + + const result = await creditsApi.getBalance(); + + expect(result).toEqual({ promotionBalanceUsd: 12.5, teamTopupUsd: 4 }); + }); + + it('accepts nested balance payloads used by some adapters', async () => { + mockCallCoreCommand.mockResolvedValue({ + data: { promotional_balance_usd: 6, team_topup_balance_usd: '9.25' }, + }); + + const result = await creditsApi.getBalance(); + + expect(result).toEqual({ promotionBalanceUsd: 6, teamTopupUsd: 9.25 }); + }); }); diff --git a/app/src/services/api/creditsApi.ts b/app/src/services/api/creditsApi.ts index 8d7342f43..7c1e931e5 100644 --- a/app/src/services/api/creditsApi.ts +++ b/app/src/services/api/creditsApi.ts @@ -150,13 +150,13 @@ function asRecord(value: unknown): Record | null { : null; } -function asNumber(value: unknown): number { +function normalizeUsd(value: unknown, fallback = 0): number { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } - return 0; + return fallback; } function asStringOrNull(value: unknown): string | null { @@ -172,7 +172,7 @@ export function normalizeCouponRedeemResult(raw: unknown): CouponRedeemResult { (typeof payload.couponCode === 'string' && payload.couponCode.trim()) || (typeof payload.code === 'string' && payload.code.trim()) || '', - amountUsd: asNumber(payload.amountUsd ?? payload.amount_usd), + amountUsd: normalizeUsd(payload.amountUsd ?? payload.amount_usd), pending: Boolean(payload.pending), }; } @@ -184,7 +184,7 @@ export function normalizeRedeemedCoupon(raw: unknown): RedeemedCoupon { (typeof record.code === 'string' && record.code.trim()) || (typeof record.couponCode === 'string' && record.couponCode.trim()) || '', - amountUsd: asNumber(record.amountUsd ?? record.amount_usd), + amountUsd: normalizeUsd(record.amountUsd ?? record.amount_usd), redeemedAt: asStringOrNull(record.redeemedAt ?? record.redeemed_at), activationType: (typeof record.activationType === 'string' && record.activationType.trim()) || @@ -196,16 +196,56 @@ export function normalizeRedeemedCoupon(raw: unknown): RedeemedCoupon { }; } -function normalizeUsd(value: unknown, fallback = 0): number { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - function normalizeCreditBalance(payload: unknown): CreditBalance { const raw = (payload && typeof payload === 'object' ? payload : {}) as Record; + const nested = asRecord(raw.data) ?? asRecord(raw.balance) ?? null; + const source = nested ?? raw; + const promotionBalanceKeys = [ + 'promotionBalanceUsd', + 'promotion_balance_usd', + 'promotionalBalanceUsd', + 'promotional_balance_usd', + 'promoBalanceUsd', + 'promo_balance_usd', + ] as const; + const teamTopupKeys = [ + 'teamTopupUsd', + 'team_topup_usd', + 'teamTopUpUsd', + 'team_top_up_usd', + 'teamTopupBalanceUsd', + 'team_topup_balance_usd', + ] as const; + const missingPromotionBalance = promotionBalanceKeys.every(key => !(key in source)); + const missingTeamTopup = teamTopupKeys.every(key => !(key in source)); + + if (missingPromotionBalance || missingTeamTopup) { + console.debug('[creditsApi] normalizeCreditBalance missing expected keys', { + raw: source, + missingPromotionBalance, + missingPromotionBalanceKeys: missingPromotionBalance ? promotionBalanceKeys : [], + missingTeamTopup, + missingTeamTopupKeys: missingTeamTopup ? teamTopupKeys : [], + }); + } return { - promotionBalanceUsd: normalizeUsd(raw.promotionBalanceUsd), - teamTopupUsd: normalizeUsd(raw.teamTopupUsd), + promotionBalanceUsd: normalizeUsd( + source.promotionBalanceUsd ?? + source.promotion_balance_usd ?? + source.promotionalBalanceUsd ?? + source.promotional_balance_usd ?? + source.promoBalanceUsd ?? + source.promo_balance_usd + ), + teamTopupUsd: normalizeUsd( + source.teamTopupUsd ?? + source.team_topup_usd ?? + source.teamTopUpUsd ?? + source.team_top_up_usd ?? + source.teamTopupBalanceUsd ?? + source.team_topup_balance_usd + ), }; } diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index 0a6c62d09..38595b27d 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -1,5 +1,12 @@ import packageJson from '../../package.json'; +const APP_ENV = (import.meta.env.VITE_OPENHUMAN_APP_ENV as string | undefined) + ?.trim() + .toLowerCase(); + +const DEFAULT_BACKEND_URL = + APP_ENV === 'staging' ? 'https://staging-api.tinyhumans.ai' : 'https://api.tinyhumans.ai'; + export const CORE_RPC_URL = import.meta.env.VITE_OPENHUMAN_CORE_RPC_URL || 'http://127.0.0.1:7788/rpc'; @@ -32,7 +39,8 @@ export const SKILLS_GITHUB_REPO = export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined; /** Backend API URL (web fallback when core RPC is unavailable). */ -export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL as string | undefined; +export const BACKEND_URL = + (import.meta.env.VITE_BACKEND_URL as string | undefined)?.trim() || DEFAULT_BACKEND_URL; /** Telegram bot username used for managed DM linking when backend does not return a launch URL. */ export const TELEGRAM_BOT_USERNAME = diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 19161aae7..03939c91f 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -1,6 +1,7 @@ /// interface ImportMetaEnv { + readonly VITE_OPENHUMAN_APP_ENV?: string; readonly VITE_OPENHUMAN_CORE_RPC_URL?: string; readonly VITE_BACKEND_URL?: string; readonly VITE_SKILLS_GITHUB_REPO?: string; diff --git a/src/api/config.rs b/src/api/config.rs index 6a7317cd2..796a8554b 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -2,11 +2,19 @@ /// Default API host when `config.api_url` is unset or blank and no env override is set. pub const DEFAULT_API_BASE_URL: &str = "https://api.tinyhumans.ai"; +/// Default staging API host when the app environment is explicitly `staging`. +pub const DEFAULT_STAGING_API_BASE_URL: &str = "https://staging-api.tinyhumans.ai"; +/// Primary app-environment selector used by the core and desktop app. +pub const APP_ENV_VAR: &str = "OPENHUMAN_APP_ENV"; +/// Vite-exposed app-environment selector used by the frontend bundle. +pub const VITE_APP_ENV_VAR: &str = "VITE_OPENHUMAN_APP_ENV"; /// Resolves the hosted API base URL (no path suffix). /// /// Order: non-empty `api_url` from config → [`api_base_from_env`] (`BACKEND_URL`, then `VITE_BACKEND_URL`) -/// → [`DEFAULT_API_BASE_URL`] (same host your curl command used when config is blank). +/// → [`default_api_base_url_for_env`](`app_env_from_env().as_deref()`), which selects the +/// environment-aware default host based on the current app environment instead of always using +/// [`DEFAULT_API_BASE_URL`]. pub fn effective_api_url(api_url: &Option) -> String { if let Some(u) = api_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) { return normalize_api_base_url(u); @@ -14,7 +22,7 @@ pub fn effective_api_url(api_url: &Option) -> String { if let Some(env_url) = api_base_from_env() { return env_url; } - DEFAULT_API_BASE_URL.to_string() + default_api_base_url_for_env(app_env_from_env().as_deref()).to_string() } /// Trim and strip trailing slashes so paths join consistently. @@ -30,3 +38,48 @@ pub fn api_base_from_env() -> Option { .map(|s| normalize_api_base_url(&s)) .filter(|s| !s.is_empty()) } + +/// Resolve the app environment from process environment. +pub fn app_env_from_env() -> Option { + std::env::var(APP_ENV_VAR) + .or_else(|_| std::env::var(VITE_APP_ENV_VAR)) + .ok() + .map(|s| s.trim().to_ascii_lowercase()) + .filter(|s| !s.is_empty()) +} + +pub fn is_staging_app_env(app_env: Option<&str>) -> bool { + matches!(app_env.map(str::trim), Some(env) if env.eq_ignore_ascii_case("staging")) +} + +pub fn default_api_base_url_for_env(app_env: Option<&str>) -> &'static str { + if is_staging_app_env(app_env) { + DEFAULT_STAGING_API_BASE_URL + } else { + DEFAULT_API_BASE_URL + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn staging_app_env_uses_staging_default_api() { + assert_eq!( + default_api_base_url_for_env(Some("staging")), + DEFAULT_STAGING_API_BASE_URL + ); + assert!(is_staging_app_env(Some("STAGING"))); + } + + #[test] + fn non_staging_app_env_uses_production_default_api() { + assert_eq!( + default_api_base_url_for_env(Some("production")), + DEFAULT_API_BASE_URL + ); + assert_eq!(default_api_base_url_for_env(None), DEFAULT_API_BASE_URL); + assert!(!is_staging_app_env(Some("development"))); + } +} diff --git a/src/openhuman/agent/harness/definition_loader.rs b/src/openhuman/agent/harness/definition_loader.rs index 605a1da5e..603c602ae 100644 --- a/src/openhuman/agent/harness/definition_loader.rs +++ b/src/openhuman/agent/harness/definition_loader.rs @@ -123,7 +123,16 @@ fn user_home_agents_dir() -> Option { if let Ok(custom) = std::env::var("OPENHUMAN_HOME") { return Some(PathBuf::from(custom).join("agents")); } - dirs::home_dir().map(|h| h.join(".openhuman").join("agents")) + match crate::openhuman::config::default_root_openhuman_dir() { + Ok(dir) => Some(dir.join("agents")), + Err(error) => { + tracing::debug!( + error = %error, + "[agent-definition-loader] resolving root openhuman dir failed" + ); + None + } + } } #[cfg(test)] diff --git a/src/openhuman/channels/controllers/definitions.rs b/src/openhuman/channels/controllers/definitions.rs index 6c7797123..537c022ed 100644 --- a/src/openhuman/channels/controllers/definitions.rs +++ b/src/openhuman/channels/controllers/definitions.rs @@ -4,15 +4,18 @@ use serde::{Deserialize, Serialize}; /// Which authentication mode a channel connection uses. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] pub enum ChannelAuthMode { /// User provides an API key or access token. + #[serde(rename = "api_key")] ApiKey, /// User provides a bot token (e.g. Telegram BotFather token). + #[serde(rename = "bot_token")] BotToken, /// User authenticates via OAuth (server-side flow). + #[serde(rename = "oauth")] OAuth, /// User messages the platform's managed bot directly. + #[serde(rename = "managed_dm")] ManagedDm, } @@ -427,4 +430,52 @@ mod tests { assert_eq!(parsed, mode); } } + + #[test] + fn auth_mode_serializes_to_expected_wire_values() { + assert_eq!( + serde_json::to_value(ChannelAuthMode::ApiKey).expect("serialize"), + serde_json::Value::String("api_key".to_string()) + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String( + "api_key".to_string() + )) + .expect("deserialize"), + ChannelAuthMode::ApiKey + ); + assert_eq!( + serde_json::to_value(ChannelAuthMode::BotToken).expect("serialize"), + serde_json::Value::String("bot_token".to_string()) + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String( + "bot_token".to_string() + )) + .expect("deserialize"), + ChannelAuthMode::BotToken + ); + assert_eq!( + serde_json::to_value(ChannelAuthMode::OAuth).expect("serialize"), + serde_json::Value::String("oauth".to_string()) + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String( + "oauth".to_string() + )) + .expect("deserialize"), + ChannelAuthMode::OAuth + ); + assert_eq!( + serde_json::to_value(ChannelAuthMode::ManagedDm).expect("serialize"), + serde_json::Value::String("managed_dm".to_string()) + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String( + "managed_dm".to_string() + )) + .expect("deserialize"), + ChannelAuthMode::ManagedDm + ); + } } diff --git a/src/openhuman/channels/providers/web.rs b/src/openhuman/channels/providers/web.rs index 770ce58dd..58c6d6649 100644 --- a/src/openhuman/channels/providers/web.rs +++ b/src/openhuman/channels/providers/web.rs @@ -1,4 +1,5 @@ use once_cell::sync::Lazy; +use regex::Regex; use serde::Deserialize; use serde_json::{json, Map, Value}; use std::collections::HashMap; @@ -75,6 +76,17 @@ static THREAD_SESSIONS: Lazy>> = static IN_FLIGHT: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +static BUDGET_ERROR_NORMALIZE_RE: Lazy = + Lazy::new(|| Regex::new(r"[-_\s]+").expect("budget normalize regex")); +static BUDGET_ERROR_PATTERNS: Lazy> = Lazy::new(|| { + vec![ + Regex::new(r"budget.*exceed").expect("budget exceeded regex"), + Regex::new(r"top up").expect("top up regex"), + Regex::new(r"add.*credits").expect("add credits regex"), + Regex::new(r"out of credits").expect("out of credits regex"), + Regex::new(r"no remaining credits").expect("no remaining credits regex"), + ] +}); fn key_for(client_id: &str, thread_id: &str) -> String { format!("{client_id}::{thread_id}") @@ -88,6 +100,19 @@ fn event_session_id_for(client_id: &str, thread_id: &str) -> String { .to_string() } +fn is_inference_budget_exceeded_error(message: &str) -> bool { + let normalized = BUDGET_ERROR_NORMALIZE_RE + .replace_all(&message.trim().to_ascii_lowercase(), " ") + .into_owned(); + BUDGET_ERROR_PATTERNS + .iter() + .any(|pattern| pattern.is_match(&normalized)) +} + +fn inference_budget_exceeded_user_message() -> &'static str { + "I don't have any budget available right now. Please top up your credits or choose a plan to continue." +} + pub async fn start_chat( client_id: &str, thread_id: &str, @@ -335,7 +360,23 @@ async fn run_chat_task( request_id.to_string(), ); - let result = agent.run_single(message).await.map_err(|e| e.to_string()); + let result = match agent.run_single(message).await { + Ok(response) => Ok(response), + Err(err) => { + let err_message = err.to_string(); + if is_inference_budget_exceeded_error(&err_message) { + log::warn!( + "[web-channel] inference budget exhausted for client={} thread={} request_id={} error_category=budget_exhausted", + client_id, + thread_id, + request_id + ); + Ok(inference_budget_exceeded_user_message().to_string()) + } else { + Err(err_message) + } + } + }; // Clear the sender so it doesn't hold the channel open across sessions. agent.set_on_progress(None); @@ -798,7 +839,10 @@ fn to_json(outcome: RpcOutcome) -> Result #[cfg(test)] mod tests { - use super::{cancel_chat, start_chat}; + use super::{ + cancel_chat, inference_budget_exceeded_user_message, is_inference_budget_exceeded_error, + start_chat, + }; #[tokio::test] async fn start_chat_validates_required_fields() { @@ -830,4 +874,24 @@ mod tests { .expect_err("thread id should be required"); assert!(err.contains("thread_id is required")); } + + #[test] + fn detects_backend_budget_exhaustion_error() { + assert!(is_inference_budget_exceeded_error( + "OpenHuman API error (402 Payment Required): Budget exceeded — add credits to continue." + )); + assert!(is_inference_budget_exceeded_error( + "provider error: budget exceeded, please add credits" + )); + assert!(!is_inference_budget_exceeded_error( + "OpenHuman API error (500): Internal server error" + )); + } + + #[test] + fn budget_exceeded_copy_mentions_top_up() { + let message = inference_budget_exceeded_user_message(); + assert!(message.contains("top up")); + assert!(message.contains("credits")); + } } diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 26cc93d06..e8237a83b 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -39,17 +39,26 @@ pub async fn load_config_with_timeout() -> Result { /// Returns the default workspace directory fallback (~/.openhuman/workspace). fn fallback_workspace_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".openhuman") + crate::openhuman::config::default_root_openhuman_dir() + .unwrap_or_else(|_| env_scoped_fallback_root_dir()) .join("workspace") } /// Returns the default OpenHuman configuration directory (~/.openhuman). fn default_openhuman_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".openhuman") + crate::openhuman::config::default_root_openhuman_dir() + .unwrap_or_else(|_| env_scoped_fallback_root_dir()) +} + +fn env_scoped_fallback_root_dir() -> PathBuf { + let suffix = if crate::api::config::is_staging_app_env( + crate::api::config::app_env_from_env().as_deref(), + ) { + "-staging" + } else { + "" + }; + PathBuf::from(format!(".openhuman{suffix}")) } /// Returns the path to the active workspace marker file. diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 5a2574524..c10468f22 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -33,6 +33,14 @@ fn default_config_dir() -> Result { default_root_openhuman_dir() } +fn default_root_dir_name() -> &'static str { + if crate::api::config::is_staging_app_env(crate::api::config::app_env_from_env().as_deref()) { + ".openhuman-staging" + } else { + ".openhuman" + } +} + /// Returns the root openhuman directory (`~/.openhuman`), independent of any /// per-user scoping. Used to locate `active_user.toml` and the shared /// `users/` tree. @@ -40,7 +48,7 @@ pub fn default_root_openhuman_dir() -> Result { let home = UserDirs::new() .map(|u| u.home_dir().to_path_buf()) .context("Could not find home directory")?; - Ok(home.join(".openhuman")) + Ok(home.join(default_root_dir_name())) } fn active_workspace_state_path(default_dir: &Path) -> PathBuf { @@ -1261,4 +1269,21 @@ mod tests { PathBuf::from("/home/test/.openhuman/users").join(PRE_LOGIN_USER_ID) ); } + + #[test] + fn default_root_dir_name_uses_staging_suffix_for_staging_env() { + let prior = std::env::var(crate::api::config::APP_ENV_VAR).ok(); + + std::env::set_var(crate::api::config::APP_ENV_VAR, "staging"); + assert!(crate::api::config::is_staging_app_env(Some("staging"))); + assert_eq!(default_root_dir_name(), ".openhuman-staging"); + + std::env::set_var(crate::api::config::APP_ENV_VAR, "production"); + assert_eq!(default_root_dir_name(), ".openhuman"); + + match prior { + Some(value) => std::env::set_var(crate::api::config::APP_ENV_VAR, value), + None => std::env::remove_var(crate::api::config::APP_ENV_VAR), + } + } } diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 08a997277..603d33735 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -198,9 +198,19 @@ pub struct Config { impl Default for Config { fn default() -> Self { - let home = - UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf()); - let openhuman_dir = home.join(".openhuman"); + let openhuman_dir = + crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|_| { + let home = UserDirs::new() + .map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf()); + let dir_name = if crate::api::config::is_staging_app_env( + crate::api::config::app_env_from_env().as_deref(), + ) { + ".openhuman-staging" + } else { + ".openhuman" + }; + home.join(dir_name) + }); Self { workspace_dir: openhuman_dir.join("workspace"), diff --git a/src/openhuman/encryption/core.rs b/src/openhuman/encryption/core.rs index 20dfb02bb..bc6ed22e7 100644 --- a/src/openhuman/encryption/core.rs +++ b/src/openhuman/encryption/core.rs @@ -101,11 +101,14 @@ impl EncryptionKey { } /// Get the path to the OpenHuman data directory. -/// If an active user is set, returns the user-scoped directory -/// (`~/.openhuman/users/{user_id}`); otherwise falls back to `~/.openhuman/`. +/// If an active user is set, returns the user-scoped directory under the +/// env-aware root returned by `default_root_openhuman_dir()` +/// (for example `~/.openhuman/users/{user_id}` in production or +/// `~/.openhuman-staging/users/{user_id}` when `OPENHUMAN_APP_ENV=staging`); +/// otherwise it falls back to that root directory itself. pub fn get_data_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?; - let root_dir = home.join(".openhuman"); + let root_dir = crate::openhuman::config::default_root_openhuman_dir() + .map_err(|e| format!("Cannot determine app data directory: {e}"))?; std::fs::create_dir_all(&root_dir) .map_err(|e| format!("Failed to create data directory: {e}"))?; @@ -121,7 +124,8 @@ pub fn get_data_dir() -> Result { Ok(data_dir) } -/// Get the path to the encryption key file (~/.openhuman/encryption.key). +/// Get the path to the encryption key file under the env-aware OpenHuman root +/// (for example `~/.openhuman/encryption.key` or `~/.openhuman-staging/encryption.key`). fn get_key_file_path() -> Result { Ok(get_data_dir()?.join("encryption.key")) } diff --git a/src/openhuman/memory/global.rs b/src/openhuman/memory/global.rs index 35a7a44b7..54655b3c9 100644 --- a/src/openhuman/memory/global.rs +++ b/src/openhuman/memory/global.rs @@ -49,9 +49,8 @@ pub fn init(workspace_dir: PathBuf) -> Result { /// Initialise using the default `.openhuman/workspace` directory. pub fn init_default() -> Result { - let workspace_dir = dirs::home_dir() - .ok_or_else(|| "failed to resolve home directory".to_string())? - .join(".openhuman") + let workspace_dir = crate::openhuman::config::default_root_openhuman_dir() + .map_err(|e| e.to_string())? .join("workspace"); init(workspace_dir) } diff --git a/src/openhuman/memory/store/client.rs b/src/openhuman/memory/store/client.rs index 00c2240a5..e96d5fc7f 100644 --- a/src/openhuman/memory/store/client.rs +++ b/src/openhuman/memory/store/client.rs @@ -64,9 +64,8 @@ impl MemoryClient { /// Returns an error string if the home directory cannot be resolved or if /// initialization fails. pub fn new_local() -> Result { - let workspace_dir = dirs::home_dir() - .ok_or_else(|| "Failed to resolve home directory".to_string())? - .join(".openhuman") + let workspace_dir = crate::openhuman::config::default_root_openhuman_dir() + .map_err(|e| e.to_string())? .join("workspace"); Self::from_workspace_dir(workspace_dir) } diff --git a/yarn.lock b/yarn.lock index 929ce5bdc..008748d12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6727,6 +6727,11 @@ react-dom@^19.1.0: dependencies: scheduler "^0.27.0" +react-icons@^5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.6.0.tgz#27bcc4acbc836e762548d76041cf9b9fef4e3837" + integrity sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA== + react-is@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"