mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(home): banners, welcome typewriter, conversation gating (#936)
This commit is contained in:
@@ -410,6 +410,10 @@ jobs:
|
||||
# come from the static `app/src-tauri/tauri.conf.json`.
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
BASE_URL: ${{ needs.prepare-build.outputs.base_url }}
|
||||
UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }}
|
||||
UPDATER_ENDPOINT: ${{ vars.UPDATER_ENDPOINT }}
|
||||
UPDATER_REPO: tinyhumansai/openhuman
|
||||
WITH_UPDATER: "true"
|
||||
with:
|
||||
script: |
|
||||
@@ -522,6 +526,7 @@ jobs:
|
||||
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 }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }}
|
||||
WITH_UPDATER: "true"
|
||||
VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }}
|
||||
VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }}
|
||||
TAURI_CONFIG_OVERRIDE: ${{ steps.config-overrides.outputs.json }}
|
||||
|
||||
Generated
+1
-1
@@ -4420,7 +4420,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openhuman"
|
||||
version = "0.53.0"
|
||||
version = "0.53.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "OpenHuman"
|
||||
version = "0.53.0"
|
||||
version = "0.53.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
@@ -384,25 +384,35 @@ export default function ComposioConnectModal({
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-sage-700">
|
||||
<div className="w-2 h-2 rounded-full bg-sage-500" />
|
||||
{toolkit.name} is connected.
|
||||
<div>
|
||||
{toolkit.name} is connected.
|
||||
{activeConnection && (
|
||||
<span className="text-[11px] text-stone-400 font-mono">
|
||||
(id: {activeConnection.id})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeConnection && (
|
||||
<p className="text-[11px] text-stone-400 font-mono break-all">
|
||||
id: {activeConnection.id}
|
||||
</p>
|
||||
)}
|
||||
<ScopeToggles
|
||||
scopes={scopes}
|
||||
savingScope={savingScope}
|
||||
onToggle={handleToggleScope}
|
||||
error={scopeError}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDisconnect()}
|
||||
className="w-full rounded-xl border border-coral-200 bg-coral-50 text-coral-700 text-sm font-medium py-2.5 hover:bg-coral-100 transition-colors">
|
||||
Disconnect
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDisconnect()}
|
||||
className="w-full rounded-xl border border-coral-200 bg-coral-50 text-coral-700 text-sm font-medium py-2.5 hover:bg-coral-100 transition-colors">
|
||||
Disconnect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { DISCORD_INVITE_URL } from '../../utils/links';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
|
||||
const BILLING_DASHBOARD_URL = 'https://tinyhumans.ai/dashboard';
|
||||
|
||||
function formatUsd(amount: number): string {
|
||||
return `$${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`;
|
||||
}
|
||||
|
||||
export function UsageLimitBanner({
|
||||
tone,
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
ctaLabel,
|
||||
}: {
|
||||
tone: 'warning' | 'danger';
|
||||
icon: string;
|
||||
title: string;
|
||||
message: string;
|
||||
ctaLabel: string;
|
||||
}) {
|
||||
const styles =
|
||||
tone === 'danger'
|
||||
? {
|
||||
card: 'border-coral-200 bg-gradient-to-r from-coral-50 via-rose-50 to-orange-50',
|
||||
title: 'text-coral-700',
|
||||
body: 'text-coral-500',
|
||||
button: 'border-coral-700 text-coral-700 hover:text-coral-800',
|
||||
}
|
||||
: {
|
||||
card: 'border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50',
|
||||
title: 'text-amber-700',
|
||||
body: 'text-amber-600',
|
||||
button: 'border-amber-700 text-amber-700 hover:text-amber-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border px-4 py-4 text-left shadow-soft ${styles.card}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-semibold ${styles.title}`}>{title}</p>
|
||||
<p className={`mt-1 text-sm leading-relaxed ${styles.body}`}>
|
||||
{message}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
className={`cursor-pointer border-b border-dashed font-bold ${styles.button}`}>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromotionalCreditsBanner({ promoCredits }: { promoCredits: number }) {
|
||||
return (
|
||||
<div className="mb-3 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-4 py-4 text-left shadow-soft">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-lg">
|
||||
🎉
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-amber-700">
|
||||
You have {formatUsd(promoCredits)} of promotional credits.
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-amber-600">
|
||||
Give OpenHuman a spin, and when you're ready for more,{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
className="cursor-pointer border-b border-amber-700 border-dashed font-bold text-amber-700 hover:text-amber-800">
|
||||
get a subscription
|
||||
</button>{' '}
|
||||
and get 10x more usage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscordBanner() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openUrl(DISCORD_INVITE_URL);
|
||||
}}
|
||||
className="mb-3 text-left mt-3 block rounded-2xl border border-[#CDD2FF] bg-gradient-to-r from-[#F6F7FF] via-[#F1F3FF] to-[#ECEFFF] px-4 py-4 text-[#414AAE] shadow-soft transition-transform transition-colors hover:-translate-y-0.5 hover:border-[#BCC3FF] hover:from-[#EEF0FF] hover:to-[#E5E9FF]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2]/12 text-[#5865F2]">
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.37A19.79 19.79 0 0 0 15.885 3c-.191.328-.403.775-.552 1.124a18.27 18.27 0 0 0-5.29 0A11.56 11.56 0 0 0 9.49 3a19.74 19.74 0 0 0-4.433 1.37C2.253 8.51 1.492 12.55 1.872 16.533a19.9 19.9 0 0 0 5.239 2.673c.423-.58.8-1.196 1.123-1.845a12.84 12.84 0 0 1-1.767-.85c.148-.106.292-.217.43-.332c3.408 1.6 7.104 1.6 10.472 0c.14.115.283.226.43.332c-.565.338-1.157.623-1.771.851c.322.648.698 1.264 1.123 1.844a19.84 19.84 0 0 0 5.241-2.673c.446-4.617-.761-8.621-3.787-12.164ZM9.46 14.088c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.82 2.084-1.855 2.084Zm5.08 0c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.812 2.084-1.855 2.084Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">Join Our Discord</div>
|
||||
<div className="mt-0.5 text-sm text-[#5E66BC]">
|
||||
Get updates, free merch, credits, report bugs, and be part of the OpenHuman community.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DISCORD_INVITE_URL } from '../../../utils/links';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import { DiscordBanner, PromotionalCreditsBanner, UsageLimitBanner } from '../HomeBanners';
|
||||
|
||||
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
describe('HomeBanners', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the billing dashboard through openUrl from the usage limit banner', () => {
|
||||
render(
|
||||
<UsageLimitBanner
|
||||
tone="warning"
|
||||
icon="⏳"
|
||||
title="Limit"
|
||||
message="Usage is capped."
|
||||
ctaLabel="Buy top-up credits"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Buy top-up credits' }));
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard');
|
||||
});
|
||||
|
||||
it('opens the billing dashboard through openUrl from the promotional credits banner', () => {
|
||||
render(<PromotionalCreditsBanner promoCredits={12} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'get a subscription' }));
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard');
|
||||
});
|
||||
|
||||
it('opens the Discord invite through openUrl from the Discord banner', () => {
|
||||
render(<DiscordBanner />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /join our discord/i }));
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith(DISCORD_INVITE_URL);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,39 @@ interface ConversationsProps {
|
||||
variant?: 'page' | 'sidebar';
|
||||
}
|
||||
|
||||
export function isComposerInteractionBlocked(args: {
|
||||
activeThreadId: string | null;
|
||||
welcomePending: boolean;
|
||||
rustChat: boolean;
|
||||
}): boolean {
|
||||
return !args.rustChat || Boolean(args.activeThreadId) || args.welcomePending;
|
||||
}
|
||||
|
||||
function WelcomeThinkingTypewriter() {
|
||||
const text = 'Your agent is thinking...';
|
||||
const [visibleChars, setVisibleChars] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const isComplete = visibleChars >= text.length;
|
||||
const delayMs = isComplete ? 950 : 42;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setVisibleChars(current => (current >= text.length ? 0 : current + 1));
|
||||
}, delayMs);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [text.length, visibleChars]);
|
||||
|
||||
return (
|
||||
<p className="flex items-center text-sm text-stone-600 font-mono tracking-tight">
|
||||
<span>{text.slice(0, visibleChars)}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-0.5 inline-block h-4 w-px bg-stone-400 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
@@ -422,7 +455,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
const normalized = text ?? inputValue;
|
||||
const trimmed = normalized.trim();
|
||||
|
||||
if (!trimmed || !selectedThreadId || composerBlocked) return;
|
||||
if (!trimmed || !selectedThreadId || composerInteractionBlocked) return;
|
||||
|
||||
if (handleSlashCommand(trimmed)) return;
|
||||
|
||||
@@ -443,8 +476,6 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (composerBlocked) return;
|
||||
|
||||
const sendingThreadId = selectedThreadId;
|
||||
const userMessage: ThreadMessage = {
|
||||
id: `msg_${globalThis.crypto.randomUUID()}`,
|
||||
@@ -743,9 +774,14 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
? (streamingAssistantByThread[selectedThreadId] ?? null)
|
||||
: null;
|
||||
const inlineCompletionSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue);
|
||||
// composerBlocked: any thread is in-flight (blocks ALL sends/voice actions).
|
||||
// Blocks all composer interaction while a turn is in-flight, the
|
||||
// proactive welcome opener is pending, or Rust chat is unavailable.
|
||||
// isSending: the *selected* thread is in-flight (drives selected-thread UI only).
|
||||
const composerBlocked = Boolean(activeThreadId);
|
||||
const composerInteractionBlocked = isComposerInteractionBlocked({
|
||||
activeThreadId,
|
||||
welcomePending,
|
||||
rustChat,
|
||||
});
|
||||
const isSending = Boolean(
|
||||
selectedThreadId &&
|
||||
(inferenceTurnLifecycleByThread[selectedThreadId] === 'started' ||
|
||||
@@ -1232,7 +1268,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
<span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
<p className="text-sm text-stone-600">Your agent is thinking...</p>
|
||||
<WelcomeThinkingTypewriter />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
@@ -1406,7 +1442,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
disabled={isSending || !rustChat}
|
||||
disabled={composerInteractionBlocked}
|
||||
className="relative z-10 w-full resize-none border-0 bg-transparent pl-4 pr-10 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 placeholder:text-stone-400 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
|
||||
@@ -1415,7 +1451,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
onClick={() => {
|
||||
void handleSendMessage();
|
||||
}}
|
||||
disabled={!inputValue.trim() || isSending || !rustChat}
|
||||
disabled={!inputValue.trim() || composerInteractionBlocked}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0">
|
||||
{isSending ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
|
||||
+109
-356
@@ -1,28 +1,59 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ConnectionIndicator from '../components/ConnectionIndicator';
|
||||
import {
|
||||
DiscordBanner,
|
||||
PromotionalCreditsBanner,
|
||||
UsageLimitBanner,
|
||||
} from '../components/home/HomeBanners';
|
||||
import { useUsageState } from '../hooks/useUsageState';
|
||||
import { useUser } from '../hooks/useUser';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { selectSocketStatus } from '../store/socketSelectors';
|
||||
import {
|
||||
bootstrapLocalAiWithRecommendedPreset,
|
||||
ensureRecommendedLocalAiPresetIfNeeded,
|
||||
triggerLocalAiAssetBootstrap,
|
||||
} from '../utils/localAiBootstrap';
|
||||
import { formatBytes, formatEta, progressFromStatus } from '../utils/localAiHelpers';
|
||||
import {
|
||||
isTauri,
|
||||
type LocalAiAssetsStatus,
|
||||
type LocalAiStatus,
|
||||
openhumanLocalAiAssetsStatus,
|
||||
openhumanLocalAiStatus,
|
||||
} from '../utils/tauriCommands';
|
||||
import { APP_VERSION } from '../utils/config';
|
||||
|
||||
export function resolveHomeUserName(user: unknown): string {
|
||||
if (!user || typeof user !== 'object') return 'User';
|
||||
|
||||
const record = user as Record<string, unknown>;
|
||||
const firstName =
|
||||
(typeof record.firstName === 'string' && record.firstName.trim()) ||
|
||||
(typeof record.first_name === 'string' && record.first_name.trim()) ||
|
||||
'';
|
||||
const lastName =
|
||||
(typeof record.lastName === 'string' && record.lastName.trim()) ||
|
||||
(typeof record.last_name === 'string' && record.last_name.trim()) ||
|
||||
'';
|
||||
const username = typeof record.username === 'string' ? record.username.trim() : '';
|
||||
const email = typeof record.email === 'string' ? record.email.trim() : '';
|
||||
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim();
|
||||
if (fullName) return fullName;
|
||||
if (firstName) return firstName;
|
||||
if (username) return username.startsWith('@') ? username : `@${username}`;
|
||||
if (email) return email.split('@')[0] || 'User';
|
||||
return 'User';
|
||||
}
|
||||
|
||||
const Home = () => {
|
||||
const { user } = useUser();
|
||||
const navigate = useNavigate();
|
||||
const userName = user?.firstName || 'User';
|
||||
const { isRateLimited, shouldShowBudgetCompletedMessage } = useUsageState();
|
||||
const _userName = resolveHomeUserName(user);
|
||||
const userName = _userName.split(' ')[0]; // Get first name only
|
||||
const promoCredits = user?.usage?.promotionBalanceUsd ?? 0;
|
||||
const isFreeTier =
|
||||
user?.subscription?.plan === 'FREE' || !user?.subscription?.hasActiveSubscription;
|
||||
const showPromoBanner = isFreeTier && promoCredits > 0;
|
||||
|
||||
const welcomeVariants = useMemo(
|
||||
() => [`Welcome, ${userName} 👋`, `Let's cook, ${userName} 🧑🍳.`, `Time to Zone In 🧘🏻`],
|
||||
[userName]
|
||||
);
|
||||
const [welcomeVariantIndex, setWelcomeVariantIndex] = useState(0);
|
||||
const [typedWelcome, setTypedWelcome] = useState('');
|
||||
const [isDeletingWelcome, setIsDeletingWelcome] = useState(false);
|
||||
// Mirror the same socket status the `ConnectionIndicator` pill consumes
|
||||
// so the description copy below the pill never contradicts it (the old
|
||||
// hard-coded "connected" message lied while the pill said "Connecting"
|
||||
@@ -30,242 +61,92 @@ const Home = () => {
|
||||
const socketStatus = useAppSelector(selectSocketStatus);
|
||||
const statusCopy = {
|
||||
connected:
|
||||
'Your device is connected to OpenHuman AI. Keep the app running to keep the connection alive — message your assistant with the button below.',
|
||||
connecting: 'Connecting to OpenHuman AI. Hang tight, this usually takes a second.',
|
||||
'Your device is connected. Keep the app running to keep the connection alive. Message your assistant with the button below.',
|
||||
connecting: 'Connecting. Hang tight, this usually takes a second.',
|
||||
disconnected:
|
||||
'Your device is offline right now. Check your network or restart the app to reconnect.',
|
||||
}[socketStatus];
|
||||
const [localAiStatus, setLocalAiStatus] = useState<LocalAiStatus | null>(null);
|
||||
const [localAiAssets, setLocalAiAssets] = useState<LocalAiAssetsStatus | null>(null);
|
||||
const [downloadBusy, setDownloadBusy] = useState(false);
|
||||
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
|
||||
const autoRetryDoneRef = useRef(false);
|
||||
const initialBootstrapHandledRef = useRef(false);
|
||||
const initialBootstrapInFlightRef = useRef(false);
|
||||
const initialBootstrapPendingDownloadRef = useRef(false);
|
||||
const initialBootstrapAttemptsRef = useRef(0);
|
||||
|
||||
// Get greeting based on time
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
// Open in-app chat.
|
||||
const handleStartCooking = async () => {
|
||||
navigate('/chat');
|
||||
};
|
||||
|
||||
const refreshLocalAiStatus = async () => {
|
||||
const status = await openhumanLocalAiStatus();
|
||||
setLocalAiStatus(status.result);
|
||||
return status.result;
|
||||
};
|
||||
|
||||
const runManualBootstrap = async (force: boolean) => {
|
||||
setDownloadBusy(true);
|
||||
setBootstrapMessage('');
|
||||
try {
|
||||
await bootstrapLocalAiWithRecommendedPreset(
|
||||
force,
|
||||
force ? '[Home re-bootstrap]' : '[Home manual bootstrap]'
|
||||
);
|
||||
const freshStatus = await refreshLocalAiStatus();
|
||||
if (freshStatus?.state === 'ready') {
|
||||
setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Local AI is ready');
|
||||
} else if (freshStatus?.state === 'degraded') {
|
||||
setBootstrapMessage('Bootstrap failed — check warning below');
|
||||
}
|
||||
setTimeout(() => setBootstrapMessage(''), 3000);
|
||||
} catch (error) {
|
||||
console.warn('[Home] manual Local AI bootstrap failed:', error);
|
||||
setBootstrapMessage('Bootstrap failed');
|
||||
setTimeout(() => setBootstrapMessage(''), 3000);
|
||||
} finally {
|
||||
setDownloadBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
const MAX_INITIAL_BOOTSTRAP_ATTEMPTS = 3;
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [status, assets] = await Promise.all([
|
||||
openhumanLocalAiStatus(),
|
||||
openhumanLocalAiAssetsStatus().catch(err => {
|
||||
console.warn('[Home] failed to load local AI assets status:', err);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
if (mounted) {
|
||||
setLocalAiStatus(status.result);
|
||||
setLocalAiAssets(assets?.result ?? null);
|
||||
const activeVariant = welcomeVariants[welcomeVariantIndex] ?? '';
|
||||
const isFullyTyped = typedWelcome === activeVariant;
|
||||
const isFullyDeleted = typedWelcome.length === 0;
|
||||
|
||||
// Auto-retry bootstrap once if Ollama is degraded (install/server issue).
|
||||
if (status.result?.state === 'degraded' && !autoRetryDoneRef.current) {
|
||||
autoRetryDoneRef.current = true;
|
||||
console.debug('[Home] local AI is degraded; scheduling a one-time re-bootstrap');
|
||||
void bootstrapLocalAiWithRecommendedPreset(true, '[Home degraded auto-retry]').catch(
|
||||
error => {
|
||||
autoRetryDoneRef.current = false;
|
||||
console.warn('[Home] degraded local AI re-bootstrap failed:', error);
|
||||
}
|
||||
);
|
||||
}
|
||||
const delay = isDeletingWelcome
|
||||
? 36
|
||||
: isFullyTyped
|
||||
? 1400
|
||||
: typedWelcome.length === 0
|
||||
? 250
|
||||
: 55;
|
||||
|
||||
if (
|
||||
status.result?.state === 'idle' &&
|
||||
!initialBootstrapHandledRef.current &&
|
||||
!initialBootstrapInFlightRef.current
|
||||
) {
|
||||
initialBootstrapInFlightRef.current = true;
|
||||
console.debug('[Home] local AI is idle; checking first-run preset selection');
|
||||
void ensureRecommendedLocalAiPresetIfNeeded('[Home first-run]')
|
||||
.then(async preset => {
|
||||
const shouldTriggerBootstrap =
|
||||
!preset.hadSelectedTier || initialBootstrapPendingDownloadRef.current;
|
||||
|
||||
if (!shouldTriggerBootstrap) {
|
||||
console.debug(
|
||||
'[Home] skipping automatic first-run bootstrap because a tier is already selected'
|
||||
);
|
||||
initialBootstrapHandledRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
initialBootstrapPendingDownloadRef.current = true;
|
||||
console.debug(
|
||||
'[Home] selected recommended preset for first-run bootstrap',
|
||||
JSON.stringify({
|
||||
recommendedTier: preset.recommendedTier,
|
||||
hadSelectedTier: preset.hadSelectedTier,
|
||||
})
|
||||
);
|
||||
await triggerLocalAiAssetBootstrap(false, '[Home first-run]');
|
||||
initialBootstrapPendingDownloadRef.current = false;
|
||||
initialBootstrapHandledRef.current = true;
|
||||
initialBootstrapAttemptsRef.current = 0;
|
||||
})
|
||||
.catch(error => {
|
||||
initialBootstrapAttemptsRef.current += 1;
|
||||
const attempts = initialBootstrapAttemptsRef.current;
|
||||
if (attempts >= MAX_INITIAL_BOOTSTRAP_ATTEMPTS) {
|
||||
initialBootstrapPendingDownloadRef.current = false;
|
||||
initialBootstrapHandledRef.current = true;
|
||||
console.warn(
|
||||
'[Home] first-run local AI bootstrap failed permanently; stopping retries',
|
||||
{ attempts, error }
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.warn('[Home] first-run local AI bootstrap failed:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
initialBootstrapInFlightRef.current = false;
|
||||
});
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (!isDeletingWelcome) {
|
||||
if (isFullyTyped) {
|
||||
setIsDeletingWelcome(true);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Home] failed to load local AI status:', error);
|
||||
if (mounted) setLocalAiStatus(null);
|
||||
|
||||
setTypedWelcome(activeVariant.slice(0, typedWelcome.length + 1));
|
||||
return;
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), 2000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const modelProgress = useMemo(() => progressFromStatus(localAiStatus), [localAiStatus]);
|
||||
// Hide the Local Model Runtime card once every capability's model file is
|
||||
// present on disk. We use `assets_status` (which inspects the filesystem)
|
||||
// instead of the in-memory `LocalAiStatus` sub-states, because the latter
|
||||
// stay at `idle` until a capability is first exercised — even when the
|
||||
// underlying model has already been downloaded.
|
||||
//
|
||||
// A capability is considered "done" when its asset state is:
|
||||
// - `ready` → model file exists on disk
|
||||
// - `disabled` → not applicable for the selected preset
|
||||
// - `ondemand` → vision preset intentionally defers download until first use
|
||||
const allModelsDownloaded = useMemo(() => {
|
||||
if (!localAiStatus || !localAiAssets) return false;
|
||||
if (localAiStatus.state !== 'ready') return false;
|
||||
const isDone = (state: string | undefined | null): boolean =>
|
||||
state === 'ready' || state === 'disabled' || state === 'ondemand';
|
||||
if (!isFullyDeleted) {
|
||||
setTypedWelcome(activeVariant.slice(0, typedWelcome.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
isDone(localAiAssets.chat?.state) &&
|
||||
isDone(localAiAssets.vision?.state) &&
|
||||
isDone(localAiAssets.embedding?.state) &&
|
||||
isDone(localAiAssets.stt?.state) &&
|
||||
isDone(localAiAssets.tts?.state)
|
||||
);
|
||||
}, [localAiStatus, localAiAssets]);
|
||||
setIsDeletingWelcome(false);
|
||||
setWelcomeVariantIndex(current => (current + 1) % welcomeVariants.length);
|
||||
}, delay);
|
||||
|
||||
const isInstalling = localAiStatus?.state === 'installing';
|
||||
const indeterminateDownload =
|
||||
isInstalling ||
|
||||
(localAiStatus?.state === 'downloading' && typeof localAiStatus.download_progress !== 'number');
|
||||
const isInstallError =
|
||||
localAiStatus?.state === 'degraded' && localAiStatus?.error_category === 'install';
|
||||
const [showErrorDetail, setShowErrorDetail] = useState(false);
|
||||
const downloadedText =
|
||||
typeof localAiStatus?.downloaded_bytes === 'number'
|
||||
? `${formatBytes(localAiStatus.downloaded_bytes)}${typeof localAiStatus?.total_bytes === 'number' ? ` / ${formatBytes(localAiStatus.total_bytes)}` : ''}`
|
||||
: '';
|
||||
const speedText =
|
||||
typeof localAiStatus?.download_speed_bps === 'number' && localAiStatus.download_speed_bps > 0
|
||||
? `${formatBytes(localAiStatus.download_speed_bps)}/s`
|
||||
: '';
|
||||
const etaText = formatEta(localAiStatus?.eta_seconds);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [isDeletingWelcome, typedWelcome, welcomeVariantIndex, welcomeVariants]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{isRateLimited && (
|
||||
<UsageLimitBanner
|
||||
tone="warning"
|
||||
icon="⏳"
|
||||
title="You’ve Hit Your Limits"
|
||||
message="You’ve reached your short-term usage cap. Buy top-up credits to keep going right away."
|
||||
ctaLabel="Buy top-up credits"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isRateLimited && shouldShowBudgetCompletedMessage && (
|
||||
<UsageLimitBanner
|
||||
tone="danger"
|
||||
icon="⚠️"
|
||||
title="You’ve Exhausted Your Usage"
|
||||
message="You’re out of included usage for now. Start a subscription to unlock more ongoing capacity."
|
||||
ctaLabel="Get a subscription"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPromoBanner && <PromotionalCreditsBanner promoCredits={promoCredits} />}
|
||||
|
||||
{/* Main card */}
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
|
||||
{/* Header row: logo + version + settings */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="w-9 h-9 bg-stone-900 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs text-stone-400">web, 01</span>
|
||||
<button
|
||||
onClick={() => navigate('/settings/messaging')}
|
||||
className="w-9 h-9 rounded-full bg-stone-100 flex items-center justify-center hover:bg-stone-200 transition-colors"
|
||||
aria-label="Notifications">
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<span className="text-xs text-center text-stone-400">v{APP_VERSION}</span>
|
||||
</div>
|
||||
|
||||
{/* Welcome title */}
|
||||
<h1 className="text-3xl font-bold text-stone-900 text-center mb-6">Welcome Onboard</h1>
|
||||
|
||||
{/* Greeting */}
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-lg font-medium text-stone-700">
|
||||
{getGreeting()}, {userName}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="min-h-[3.5rem] text-32l font-bold text-stone-900 text-center">
|
||||
{typedWelcome}
|
||||
<span aria-hidden="true" className="ml-0.5 inline-block text-primary-500 animate-pulse">
|
||||
|
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex justify-center mb-3">
|
||||
@@ -285,8 +166,10 @@ const Home = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DiscordBanner />
|
||||
|
||||
{/* Next steps — compact directory of where to go next */}
|
||||
<div className="mt-3 bg-white rounded-2xl shadow-soft border border-stone-200 p-4">
|
||||
{/* <div className="mt-3 bg-white rounded-2xl shadow-soft border border-stone-200 p-4">
|
||||
<div className="text-[11px] uppercase tracking-wide text-stone-400 mb-2">Next steps</div>
|
||||
<div className="divide-y divide-stone-100">
|
||||
<button
|
||||
@@ -356,137 +239,7 @@ const Home = () => {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local AI card (desktop only) — hidden once all models are fully downloaded */}
|
||||
{isTauri() && !allModelsDownloaded && (
|
||||
<div className="mt-3 bg-white rounded-2xl shadow-soft border border-stone-200 px-4 py-4 text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] uppercase tracking-wide text-stone-400">
|
||||
Local model runtime
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/settings/local-model')}
|
||||
className="text-xs text-primary-500 hover:text-primary-600 transition-colors">
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className="text-stone-600">
|
||||
{localAiStatus?.model_id ?? 'gemma3:4b-it-qat'}
|
||||
</span>
|
||||
<span className="text-stone-700 capitalize">
|
||||
{localAiStatus?.state ?? 'starting'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 rounded-full bg-stone-100 overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r from-primary-500 to-primary-400 transition-all duration-500 ${
|
||||
indeterminateDownload ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.round((indeterminateDownload ? 1 : modelProgress) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[11px] text-stone-400">
|
||||
<span>
|
||||
{isInstalling
|
||||
? 'Installing Ollama runtime...'
|
||||
: indeterminateDownload
|
||||
? 'Downloading...'
|
||||
: `${Math.round(modelProgress * 100)}%`}
|
||||
</span>
|
||||
{downloadedText && (
|
||||
<span className="truncate text-stone-500" title={downloadedText}>
|
||||
{downloadedText}
|
||||
</span>
|
||||
)}
|
||||
{speedText && <span className="text-primary-500">{speedText}</span>}
|
||||
{etaText && <span className="text-primary-600">ETA {etaText}</span>}
|
||||
</div>
|
||||
{localAiStatus?.warning && (
|
||||
<div
|
||||
className="mt-1 text-[11px] text-stone-400 truncate"
|
||||
title={localAiStatus.warning}>
|
||||
{localAiStatus.warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInstallError && localAiStatus?.error_detail && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setShowErrorDetail(v => !v)}
|
||||
className="text-[11px] text-coral-500 hover:text-coral-600 underline">
|
||||
{showErrorDetail ? 'Hide error details' : 'Show error details'}
|
||||
</button>
|
||||
{showErrorDetail && (
|
||||
<pre className="mt-1 max-h-32 overflow-auto rounded bg-stone-50 p-2 text-[10px] text-coral-600 leading-tight whitespace-pre-wrap break-words">
|
||||
{localAiStatus.error_detail}
|
||||
</pre>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-stone-400">
|
||||
Install Ollama manually from{' '}
|
||||
<a
|
||||
href="https://ollama.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-500 hover:text-primary-600 underline">
|
||||
ollama.com
|
||||
</a>{' '}
|
||||
then set its path in{' '}
|
||||
<button
|
||||
onClick={() => navigate('/settings/local-model')}
|
||||
className="text-primary-500 hover:text-primary-600 underline">
|
||||
Settings
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{localAiStatus?.state === 'ready' ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-green-50 px-2.5 py-1.5 text-[11px] font-medium text-green-700 border border-green-200">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Running
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void runManualBootstrap(false)}
|
||||
disabled={downloadBusy}
|
||||
className="rounded-md bg-primary-500 px-2.5 py-1.5 text-[11px] font-medium text-white hover:bg-primary-600 disabled:opacity-60">
|
||||
{downloadBusy
|
||||
? 'Working...'
|
||||
: localAiStatus?.state === 'degraded'
|
||||
? 'Retry'
|
||||
: 'Bootstrap'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void runManualBootstrap(true)}
|
||||
disabled={downloadBusy}
|
||||
className="rounded-md border border-stone-200 px-2.5 py-1.5 text-[11px] font-medium text-stone-600 hover:border-stone-300 disabled:opacity-60">
|
||||
{downloadBusy ? 'Working...' : 'Re-bootstrap'}
|
||||
</button>
|
||||
{bootstrapMessage && (
|
||||
<span className="text-[11px] text-green-600 animate-fade-up">
|
||||
{bootstrapMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isComposerInteractionBlocked } from '../Conversations';
|
||||
|
||||
describe('isComposerInteractionBlocked', () => {
|
||||
it('blocks composer interaction while the welcome agent loader is visible', () => {
|
||||
expect(
|
||||
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: true, rustChat: true })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks composer interaction while a thread is actively running', () => {
|
||||
expect(
|
||||
isComposerInteractionBlocked({
|
||||
activeThreadId: 'thread-1',
|
||||
welcomePending: false,
|
||||
rustChat: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows composer interaction when chat is idle and ready', () => {
|
||||
expect(
|
||||
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: true })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks composer interaction when rust chat is unavailable', () => {
|
||||
expect(
|
||||
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: false })
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +1,34 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import Home from '../Home';
|
||||
import { resolveHomeUserName } from '../Home';
|
||||
|
||||
const mockUseUser = vi.fn(() => ({ user: { firstName: 'Shrey' } }));
|
||||
|
||||
vi.mock('../../components/ConnectionIndicator', () => ({
|
||||
default: () => <div>Connection Indicator</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useUser', () => ({ useUser: () => ({ user: { firstName: 'Shrey' } }) }));
|
||||
vi.mock('../../hooks/useUser', () => ({ useUser: () => mockUseUser() }));
|
||||
|
||||
vi.mock('../../utils/localAiBootstrap', () => ({
|
||||
bootstrapLocalAiWithRecommendedPreset: vi.fn(),
|
||||
ensureRecommendedLocalAiPresetIfNeeded: vi.fn(),
|
||||
triggerLocalAiAssetBootstrap: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../utils/config', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../utils/config')>();
|
||||
return { ...actual, APP_VERSION: '0.0.0-test' };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/tauriCommands', () => ({
|
||||
isTauri: vi.fn(() => true),
|
||||
openhumanLocalAiStatus: vi.fn(),
|
||||
openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
|
||||
}));
|
||||
|
||||
describe('Home local AI bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
describe('resolveHomeUserName', () => {
|
||||
it('uses camelCase name fields when present', () => {
|
||||
expect(resolveHomeUserName({ firstName: 'Ada', lastName: 'Lovelace' })).toBe('Ada Lovelace');
|
||||
});
|
||||
|
||||
it('auto-applies the recommended preset and starts bootstrap on first-run idle state', async () => {
|
||||
const bootstrapUtils = await import('../../utils/localAiBootstrap');
|
||||
const tauriCommands = await import('../../utils/tauriCommands');
|
||||
|
||||
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
|
||||
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: null,
|
||||
hadSelectedTier: false,
|
||||
appliedTier: 'high',
|
||||
});
|
||||
vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap).mockResolvedValue({
|
||||
result: { state: 'downloading', progress: 0 } as never,
|
||||
logs: [],
|
||||
});
|
||||
|
||||
renderWithProviders(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith(
|
||||
'[Home first-run]'
|
||||
);
|
||||
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledWith(
|
||||
false,
|
||||
'[Home first-run]'
|
||||
);
|
||||
});
|
||||
it('falls back to snake_case name fields from core snapshot payloads', () => {
|
||||
expect(resolveHomeUserName({ first_name: 'Ada', last_name: 'Lovelace' })).toBe('Ada Lovelace');
|
||||
});
|
||||
|
||||
it('does not auto-bootstrap when a tier is already selected', async () => {
|
||||
const bootstrapUtils = await import('../../utils/localAiBootstrap');
|
||||
const tauriCommands = await import('../../utils/tauriCommands');
|
||||
|
||||
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
|
||||
result: { state: 'idle', model_id: 'gemma3:12b-it-q4_K_M' } as never,
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: 'high',
|
||||
hadSelectedTier: true,
|
||||
appliedTier: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith(
|
||||
'[Home first-run]'
|
||||
);
|
||||
});
|
||||
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).not.toHaveBeenCalled();
|
||||
it('falls back to username when no name fields are present', () => {
|
||||
expect(resolveHomeUserName({ username: 'openhuman' })).toBe('@openhuman');
|
||||
});
|
||||
|
||||
it('retries the first-run bootstrap trigger after preset application if the first trigger attempt fails', async () => {
|
||||
const bootstrapUtils = await import('../../utils/localAiBootstrap');
|
||||
const tauriCommands = await import('../../utils/tauriCommands');
|
||||
|
||||
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
|
||||
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded)
|
||||
.mockResolvedValueOnce({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: null,
|
||||
hadSelectedTier: false,
|
||||
appliedTier: 'high',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: 'high',
|
||||
hadSelectedTier: true,
|
||||
appliedTier: null,
|
||||
});
|
||||
vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap)
|
||||
.mockRejectedValueOnce(new Error('transient failure'))
|
||||
.mockResolvedValueOnce({ result: { state: 'downloading', progress: 0 } as never, logs: [] });
|
||||
|
||||
renderWithProviders(<Home />);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
false,
|
||||
'[Home first-run]'
|
||||
);
|
||||
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
false,
|
||||
'[Home first-run]'
|
||||
);
|
||||
it('falls back to the email local-part when no explicit name exists', () => {
|
||||
expect(resolveHomeUserName({ email: 'ada@example.com' })).toBe('ada');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import Home from '../Home';
|
||||
|
||||
vi.mock('../../components/ConnectionIndicator', () => ({
|
||||
default: () => <div>Connection Indicator</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useUser', () => ({ useUser: () => ({ user: { firstName: 'Tester' } }) }));
|
||||
|
||||
vi.mock('../../utils/localAiBootstrap', () => ({
|
||||
bootstrapLocalAiWithRecommendedPreset: vi.fn(),
|
||||
ensureRecommendedLocalAiPresetIfNeeded: vi.fn(),
|
||||
triggerLocalAiAssetBootstrap: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/tauriCommands', () => ({
|
||||
isTauri: vi.fn(() => true),
|
||||
openhumanLocalAiStatus: vi.fn(),
|
||||
openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
|
||||
}));
|
||||
|
||||
describe('Home bootstrap button states', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows "Running" badge instead of Bootstrap button when state is ready', async () => {
|
||||
const tauriCommands = await import('../../utils/tauriCommands');
|
||||
const bootstrapUtils = await import('../../utils/localAiBootstrap');
|
||||
|
||||
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
|
||||
result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never,
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: 'high',
|
||||
hadSelectedTier: true,
|
||||
appliedTier: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Bootstrap' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Re-bootstrap' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Retry" button when state is degraded', async () => {
|
||||
const tauriCommands = await import('../../utils/tauriCommands');
|
||||
const bootstrapUtils = await import('../../utils/localAiBootstrap');
|
||||
|
||||
// Keep returning degraded so the auto-retry doesn't change the visible state
|
||||
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
|
||||
result: {
|
||||
state: 'degraded',
|
||||
model_id: 'gemma3:4b-it-qat',
|
||||
warning: 'Ollama not found',
|
||||
} as never,
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: 'high',
|
||||
hadSelectedTier: true,
|
||||
appliedTier: null,
|
||||
});
|
||||
// The Home component auto-retries on degraded — let it resolve without changing state
|
||||
vi.mocked(bootstrapUtils.bootstrapLocalAiWithRecommendedPreset).mockResolvedValue({
|
||||
preset: {} as never,
|
||||
download: {} as never,
|
||||
});
|
||||
|
||||
renderWithProviders(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Bootstrap" button when state is idle', async () => {
|
||||
const tauriCommands = await import('../../utils/tauriCommands');
|
||||
const bootstrapUtils = await import('../../utils/localAiBootstrap');
|
||||
|
||||
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
|
||||
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: 'high',
|
||||
hadSelectedTier: true,
|
||||
appliedTier: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Bootstrap' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows success message after re-bootstrap completes', async () => {
|
||||
const tauriCommands = await import('../../utils/tauriCommands');
|
||||
const bootstrapUtils = await import('../../utils/localAiBootstrap');
|
||||
|
||||
vi.mocked(tauriCommands.openhumanLocalAiStatus)
|
||||
.mockResolvedValueOnce({
|
||||
result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never,
|
||||
logs: [],
|
||||
})
|
||||
.mockResolvedValue({
|
||||
result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never,
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
|
||||
presets: {} as never,
|
||||
recommendedTier: 'high',
|
||||
selectedTier: 'high',
|
||||
hadSelectedTier: true,
|
||||
appliedTier: null,
|
||||
});
|
||||
vi.mocked(bootstrapUtils.bootstrapLocalAiWithRecommendedPreset).mockResolvedValue({
|
||||
preset: {} as never,
|
||||
download: {} as never,
|
||||
});
|
||||
|
||||
renderWithProviders(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Re-bootstrap' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Re-bootstrap complete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,10 +78,14 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null {
|
||||
function normalizeSnapshot(
|
||||
result: Awaited<ReturnType<typeof fetchCoreAppSnapshot>>
|
||||
): CoreAppSnapshot {
|
||||
const currentUser = (result.currentUser ??
|
||||
result.auth.user ??
|
||||
null) as CoreAppSnapshot['currentUser'];
|
||||
|
||||
return {
|
||||
auth: result.auth,
|
||||
sessionToken: result.sessionToken,
|
||||
currentUser: result.currentUser,
|
||||
currentUser,
|
||||
onboardingCompleted: result.onboardingCompleted,
|
||||
chatOnboardingCompleted: result.chatOnboardingCompleted,
|
||||
analyticsEnabled: result.analyticsEnabled,
|
||||
|
||||
@@ -15,16 +15,18 @@ function makeSnapshot(overrides: {
|
||||
userId?: string | null;
|
||||
sessionToken?: string | null;
|
||||
isAuthenticated?: boolean;
|
||||
authUser?: unknown | null;
|
||||
currentUser?: unknown | null;
|
||||
}): Snapshot {
|
||||
return {
|
||||
auth: {
|
||||
isAuthenticated: overrides.isAuthenticated ?? Boolean(overrides.userId),
|
||||
userId: overrides.userId ?? null,
|
||||
user: null,
|
||||
user: (overrides.authUser ?? null) as never,
|
||||
profileId: null,
|
||||
},
|
||||
sessionToken: overrides.sessionToken ?? null,
|
||||
currentUser: null,
|
||||
currentUser: (overrides.currentUser ?? null) as never,
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
@@ -211,4 +213,30 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
|
||||
expect(screen.getByTestId('ready').textContent).toBe('boot');
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
});
|
||||
|
||||
it('backfills snapshot.currentUser from auth.user when currentUser is missing', async () => {
|
||||
fetchSnapshot.mockResolvedValue(
|
||||
makeSnapshot({
|
||||
userId: 'u1',
|
||||
sessionToken: 'tok1',
|
||||
authUser: { first_name: 'Ada', username: 'ada' },
|
||||
currentUser: null,
|
||||
})
|
||||
);
|
||||
listTeams.mockResolvedValue([]);
|
||||
|
||||
let ctx: CoreStateContextValue | undefined;
|
||||
render(
|
||||
<CoreStateProvider>
|
||||
<Consumer
|
||||
captureCtx={next => {
|
||||
ctx = next;
|
||||
}}
|
||||
/>
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
|
||||
expect(ctx?.snapshot.currentUser).toEqual({ first_name: 'Ada', username: 'ada' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ vi.mock('../utils/config', () => ({
|
||||
BACKEND_URL: 'http://localhost:5005',
|
||||
TELEGRAM_BOT_USERNAME: 'openhuman_bot',
|
||||
LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest',
|
||||
APP_VERSION: '0.0.0-test',
|
||||
DEV_JWT_TOKEN: undefined,
|
||||
}));
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UserSubscription {
|
||||
}
|
||||
|
||||
export interface IUserUsage {
|
||||
promotionBalanceUsd?: number;
|
||||
cycleBudgetUsd: number;
|
||||
spentThisCycleUsd: number;
|
||||
spentTodayUsd: number;
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use log::{debug, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -26,7 +26,17 @@ use crate::rpc::RpcOutcome;
|
||||
|
||||
const LOG_PREFIX: &str = "[app_state]";
|
||||
const APP_STATE_FILENAME: &str = "app-state.json";
|
||||
const CURRENT_USER_REFRESH_TTL: Duration = Duration::from_secs(5);
|
||||
static APP_STATE_FILE_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
static CURRENT_USER_CACHE: Lazy<Mutex<Option<CachedCurrentUser>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CachedCurrentUser {
|
||||
api_base: String,
|
||||
token: String,
|
||||
fetched_at: Instant,
|
||||
user: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -295,6 +305,58 @@ async fn fetch_current_user(config: &Config, token: &str) -> Result<Option<Value
|
||||
Ok(Some(user))
|
||||
}
|
||||
|
||||
fn sanitize_snapshot_user(user: Option<Value>) -> Option<Value> {
|
||||
match user {
|
||||
Some(Value::Object(map)) if map.is_empty() => None,
|
||||
Some(Value::Null) => None,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_current_user_cached(config: &Config, token: &str) -> Result<Option<Value>, String> {
|
||||
let api_base = effective_api_url(&config.api_url)
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
|
||||
{
|
||||
let cache = CURRENT_USER_CACHE.lock();
|
||||
if let Some(entry) = cache.as_ref() {
|
||||
if entry.api_base == api_base
|
||||
&& entry.token == token
|
||||
&& entry.fetched_at.elapsed() < CURRENT_USER_REFRESH_TTL
|
||||
{
|
||||
debug!(
|
||||
"{LOG_PREFIX} using cached current user age_ms={}",
|
||||
entry.fetched_at.elapsed().as_millis()
|
||||
);
|
||||
return Ok(Some(entry.user.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fetched = sanitize_snapshot_user(fetch_current_user(config, token).await?);
|
||||
|
||||
let mut cache = CURRENT_USER_CACHE.lock();
|
||||
match fetched.clone() {
|
||||
Some(user) => {
|
||||
debug!("{LOG_PREFIX} refreshed current user from backend");
|
||||
*cache = Some(CachedCurrentUser {
|
||||
api_base,
|
||||
token: token.to_string(),
|
||||
fetched_at: Instant::now(),
|
||||
user,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
debug!("{LOG_PREFIX} backend returned empty current user; clearing cache");
|
||||
*cache = None;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(fetched)
|
||||
}
|
||||
|
||||
async fn build_runtime_snapshot(config: &Config) -> RuntimeSnapshot {
|
||||
let screen_intelligence = {
|
||||
let _ = crate::openhuman::screen_intelligence::global_engine()
|
||||
@@ -341,15 +403,21 @@ async fn build_runtime_snapshot(config: &Config) -> RuntimeSnapshot {
|
||||
|
||||
pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let auth = build_session_state(&config)?;
|
||||
let mut auth = build_session_state(&config)?;
|
||||
let session_token = get_session_token(&config)?;
|
||||
let current_user = auth.user.clone().or(
|
||||
if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) {
|
||||
fetch_current_user(&config, &token).await?
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
let stored_user = sanitize_snapshot_user(auth.user.clone());
|
||||
let current_user = if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) {
|
||||
match fetch_current_user_cached(&config, &token).await {
|
||||
Ok(fresh_user) => fresh_user.or(stored_user.clone()),
|
||||
Err(error) => {
|
||||
warn!("{LOG_PREFIX} current user refresh failed; using stored snapshot fallback: {error}");
|
||||
stored_user.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stored_user.clone()
|
||||
};
|
||||
auth.user = current_user.clone();
|
||||
let local_state = load_stored_app_state(&config)?;
|
||||
let runtime = build_runtime_snapshot(&config).await;
|
||||
|
||||
@@ -420,3 +488,7 @@ pub async fn update_local_state(
|
||||
vec!["core local app state updated".to_string()],
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ops_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn sanitize_snapshot_user_drops_empty_payloads() {
|
||||
assert_eq!(sanitize_snapshot_user(Some(json!({}))), None);
|
||||
assert_eq!(sanitize_snapshot_user(Some(Value::Null)), None);
|
||||
assert_eq!(
|
||||
sanitize_snapshot_user(Some(json!({ "firstName": "steven" }))),
|
||||
Some(json!({ "firstName": "steven" }))
|
||||
);
|
||||
}
|
||||
|
||||
fn make_cached_entry(age: Duration) -> CachedCurrentUser {
|
||||
CachedCurrentUser {
|
||||
api_base: "https://staging-api.tinyhumans.ai".to_string(),
|
||||
token: "tok".to_string(),
|
||||
fetched_at: Instant::now() - age,
|
||||
user: json!({ "firstName": "steven" }),
|
||||
}
|
||||
}
|
||||
|
||||
// The freshness branch in `fetch_current_user_cached` is `elapsed() < TTL`.
|
||||
// Lock that contract here so a future TTL change can't silently flip the
|
||||
// cache from "hit" to "miss" without updating this test.
|
||||
#[test]
|
||||
fn cached_entry_is_considered_fresh_within_ttl() {
|
||||
let fresh = make_cached_entry(Duration::from_millis(0));
|
||||
assert!(fresh.fetched_at.elapsed() < CURRENT_USER_REFRESH_TTL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_is_considered_expired_past_ttl() {
|
||||
let expired = make_cached_entry(CURRENT_USER_REFRESH_TTL + Duration::from_millis(50));
|
||||
assert!(expired.fetched_at.elapsed() >= CURRENT_USER_REFRESH_TTL);
|
||||
}
|
||||
@@ -143,7 +143,7 @@ pub async fn store_session(
|
||||
{
|
||||
metadata.insert("user_id".to_string(), uid);
|
||||
}
|
||||
let user_for_store = user.unwrap_or(settings);
|
||||
let user_for_store = sanitize_stored_session_user(user).unwrap_or(settings);
|
||||
metadata.insert("user_json".to_string(), user_for_store.to_string());
|
||||
|
||||
// Determine user_id so we can scope the openhuman directory to this user.
|
||||
@@ -228,6 +228,14 @@ pub async fn store_session(
|
||||
Ok(RpcOutcome::new(summarize_auth_profile(&profile), logs))
|
||||
}
|
||||
|
||||
fn sanitize_stored_session_user(user: Option<serde_json::Value>) -> Option<serde_json::Value> {
|
||||
match user {
|
||||
Some(serde_json::Value::Object(map)) if map.is_empty() => None,
|
||||
Some(serde_json::Value::Null) => None,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_session(config: &Config) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let auth = AuthService::from_config(config);
|
||||
let removed = auth
|
||||
|
||||
@@ -60,6 +60,19 @@ async fn store_session_rejects_empty_or_whitespace_token() {
|
||||
assert!(err.contains("token is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_stored_session_user_discards_empty_objects() {
|
||||
assert_eq!(sanitize_stored_session_user(Some(json!({}))), None);
|
||||
assert_eq!(
|
||||
sanitize_stored_session_user(Some(serde_json::Value::Null)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_stored_session_user(Some(json!({ "firstName": "steven" }))),
|
||||
Some(json!({ "firstName": "steven" }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── clear_session ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user