+ {isRateLimited && (
+
+ )}
+
+ {!isRateLimited && shouldShowBudgetCompletedMessage && (
+
+ )}
+
+ {showPromoBanner &&
}
+
{/* Main card */}
{/* Header row: logo + version + settings */}
-
-
-
web, 01
-
+
+ v{APP_VERSION}
{/* Welcome title */}
-
Welcome Onboard
-
- {/* Greeting */}
-
-
- {getGreeting()}, {userName}
-
-
+
+ {typedWelcome}
+
+ |
+
+
{/* Connection status */}
@@ -285,8 +166,10 @@ const Home = () => {
+
+
{/* Next steps β compact directory of where to go next */}
-
+ {/*
-
- {/* Local AI card (desktop only) β hidden once all models are fully downloaded */}
- {isTauri() && !allModelsDownloaded && (
-
-
-
- Local model runtime
-
-
-
-
-
-
- {localAiStatus?.model_id ?? 'gemma3:4b-it-qat'}
-
-
- {localAiStatus?.state ?? 'starting'}
-
-
-
-
-
-
-
- {isInstalling
- ? 'Installing Ollama runtime...'
- : indeterminateDownload
- ? 'Downloading...'
- : `${Math.round(modelProgress * 100)}%`}
-
- {downloadedText && (
-
- {downloadedText}
-
- )}
- {speedText && {speedText}}
- {etaText && ETA {etaText}}
-
- {localAiStatus?.warning && (
-
- {localAiStatus.warning}
-
- )}
-
- {isInstallError && localAiStatus?.error_detail && (
-
-
- {showErrorDetail && (
-
- {localAiStatus.error_detail}
-
- )}
-
- Install Ollama manually from{' '}
-
- ollama.com
- {' '}
- then set its path in{' '}
-
- .
-
-
- )}
-
-
- {localAiStatus?.state === 'ready' ? (
-
-
- Running
-
- ) : (
-
- )}
-
- {bootstrapMessage && (
-
- {bootstrapMessage}
-
- )}
-
-
- )}
+
*/}
);
diff --git a/app/src/pages/__tests__/Conversations.test.tsx b/app/src/pages/__tests__/Conversations.test.tsx
new file mode 100644
index 000000000..3cc655d43
--- /dev/null
+++ b/app/src/pages/__tests__/Conversations.test.tsx
@@ -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);
+ });
+});
diff --git a/app/src/pages/__tests__/Home.test.tsx b/app/src/pages/__tests__/Home.test.tsx
index c1a3ab396..c3e739233 100644
--- a/app/src/pages/__tests__/Home.test.tsx
+++ b/app/src/pages/__tests__/Home.test.tsx
@@ -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: () =>
Connection Indicator
,
}));
-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
();
+ 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();
-
- 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();
-
- 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();
-
- 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');
});
});
diff --git a/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx b/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx
deleted file mode 100644
index 5e24ba040..000000000
--- a/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx
+++ /dev/null
@@ -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: () => Connection Indicator
,
-}));
-
-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();
-
- 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();
-
- 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();
-
- 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();
-
- await waitFor(() => {
- expect(screen.getByText('Running')).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByRole('button', { name: 'Re-bootstrap' }));
-
- await waitFor(() => {
- expect(screen.getByText('Re-bootstrap complete')).toBeInTheDocument();
- });
- });
-});
diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx
index 1d5ce8ad5..7055f0c6e 100644
--- a/app/src/providers/CoreStateProvider.tsx
+++ b/app/src/providers/CoreStateProvider.tsx
@@ -78,10 +78,14 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null {
function normalizeSnapshot(
result: Awaited>
): 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,
diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx
index ac473730d..f8a0d0847 100644
--- a/app/src/providers/__tests__/CoreStateProvider.test.tsx
+++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx
@@ -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(
+
+ {
+ ctx = next;
+ }}
+ />
+
+ );
+
+ await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
+ expect(ctx?.snapshot.currentUser).toEqual({ first_name: 'Ada', username: 'ada' });
+ });
});
diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts
index 1d7e467e4..f7bcd6737 100644
--- a/app/src/test/setup.ts
+++ b/app/src/test/setup.ts
@@ -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,
}));
diff --git a/app/src/types/api.ts b/app/src/types/api.ts
index d3a0b8f53..26134ed66 100644
--- a/app/src/types/api.ts
+++ b/app/src/types/api.ts
@@ -22,6 +22,7 @@ export interface UserSubscription {
}
export interface IUserUsage {
+ promotionBalanceUsd?: number;
cycleBudgetUsd: number;
spentThisCycleUsd: number;
spentTodayUsd: number;
diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs
index 29a63544c..4ac2b04b1 100644
--- a/src/openhuman/app_state/ops.rs
+++ b/src/openhuman/app_state/ops.rs
@@ -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> = Lazy::new(|| Mutex::new(()));
+static CURRENT_USER_CACHE: Lazy>> = 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