Skip to content

Commit 8486a3b

Browse files
authored
feat(onboarding): calmer onboarding + local AI download snackbar (tinyhumansai#134)
* feat(onboarding): calmer onboarding with durable deferral + local AI download snackbar - Add persisted `onboardingDeferredByUser` state so "Set up later" survives across sessions (no more overlay nagging on every launch) - Add SetupBanner for non-intrusive "Finish setting up" reminder with resume path - Convert LocalAIStep to fire-and-forget download, advancing immediately - Add LocalAIDownloadSnackbar (bottom-left, collapsible) for persistent download progress that doesn't block the main chrome - Extract shared helpers (formatBytes, formatEta, progressFromStatus) to localAiHelpers.ts - Add Vitest tests for overlay gating, banner, and snackbar Closes tinyhumansai#101 * fix(test): remove per-test config mock that overrode global setup exports The OnboardingOverlay test mocked utils/config with only DEV_FORCE_ONBOARDING, which shadowed the global mock from setup.ts and dropped IS_DEV — causing store/index.ts to fail on CI.
1 parent 600dab4 commit 8486a3b

14 files changed

Lines changed: 595 additions & 213 deletions

‎.claude/memory.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ Quick reference for anyone starting with Claude on this project. Updated by the
3434
- **Default config maps to Medium tier** (`gemma3:4b-it-qat`). If someone changes `model_ids.rs` defaults, they should keep `presets.rs` in sync.
3535
- **Daemon binary gotcha** — A daemon process (`openhuman-aarch64-apple-darwin run`) auto-starts on port 7788 and respawns on kill. `yarn tauri dev` reuses it if already running. When adding new RPC methods, you must replace this binary: `cp -f target/debug/openhuman-core app/src-tauri/binaries/openhuman-aarch64-apple-darwin`, then kill the old PID so it respawns with the new binary.
3636

37+
## Onboarding System
38+
39+
- **OnboardingOverlay is a portal, not a route** — mounted in `App.tsx`, renders via `createPortal` at z-[9999]. There is no `/onboarding` route in `AppRoutes.tsx`. Gating is purely Redux + workspace flag.
40+
- **Deferred onboarding** — `onboardingDeferredByUser` in `authSlice.ts` (persisted via redux-persist) durably tracks when a user clicks "Set up later". `SetupBanner.tsx` provides the resume path.
41+
- **`selectHasIncompleteOnboarding` is unused** in production code — only tested. Don't use it for new features.
42+
- **`formatBytes` / `formatEta` / `progressFromStatus`** — shared in `app/src/utils/localAiHelpers.ts`. Home.tsx and LocalModelPanel.tsx still have local copies (can be migrated later).
43+
- **Notification z-index stacking** — ErrorReportNotification: z-[10000] bottom-right. OnboardingOverlay: z-[9999]. LocalAIDownloadSnackbar: z-[9998] bottom-left.
44+
- **React Compiler lint** — `useCallback` deps must match the full inferred closure. Using `user?._id` as dep when the closure captures `user` triggers `preserve-manual-memoization`. Use `user` as the dep instead.
45+
- **`setState` in effects** — ESLint `react-hooks/set-state-in-effect` catches synchronous setState in useEffect bodies. Use lazy initializers, compute at render, or event handlers instead.
46+
3747
## Environment
3848

3949
- **Core sidecar port** — `7788` (default). Check with `lsof -i :7788`.

‎app/src/App.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { PersistGate } from 'redux-persist/integration/react';
66
import AppRoutes from './AppRoutes';
77
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
88
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
9+
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
910
import MiniSidebar from './components/MiniSidebar';
1011
import OnboardingOverlay from './components/OnboardingOverlay';
12+
import SetupBanner from './components/SetupBanner';
1113
import SocketProvider from './providers/SocketProvider';
1214
import UserProvider from './providers/UserProvider';
1315
import { tagErrorSource } from './services/errorReportQueue';
@@ -43,6 +45,7 @@ function App() {
4345
<div className="flex-1 flex overflow-hidden">
4446
<MiniSidebar />
4547
<div className="flex flex-col flex-1 relative overflow-hidden">
48+
<SetupBanner />
4649
<div className="flex-1 overflow-y-auto">
4750
<AppRoutes />
4851
</div>
@@ -55,6 +58,7 @@ function App() {
5558
</div>
5659
</div>
5760
<OnboardingOverlay />
61+
<LocalAIDownloadSnackbar />
5862
</ServiceBlockingGate>
5963
</Router>
6064
</SocketProvider>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
import { createPortal } from 'react-dom';
3+
4+
import {
5+
formatBytes,
6+
formatEta,
7+
progressFromDownloads,
8+
statusLabel,
9+
} from '../utils/localAiHelpers';
10+
import {
11+
isTauri,
12+
type LocalAiDownloadsProgress,
13+
type LocalAiStatus,
14+
openhumanLocalAiDownloadsProgress,
15+
openhumanLocalAiStatus,
16+
} from '../utils/tauriCommands';
17+
18+
const POLL_INTERVAL = 2000;
19+
20+
/**
21+
* Persistent snackbar that shows local AI download progress.
22+
* Anchored bottom-left to avoid conflict with ErrorReportNotification (bottom-right).
23+
* Dismiss hides the UI but does NOT cancel the download.
24+
*/
25+
const LocalAIDownloadSnackbar = () => {
26+
const [status, setStatus] = useState<LocalAiStatus | null>(null);
27+
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
28+
const [dismissed, setDismissed] = useState(false);
29+
const [collapsed, setCollapsed] = useState(false);
30+
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
31+
32+
// Check Tauri availability once at init
33+
const tauriAvailable = (() => {
34+
try {
35+
return isTauri();
36+
} catch {
37+
return false;
38+
}
39+
})();
40+
41+
// Poll download status
42+
useEffect(() => {
43+
if (!tauriAvailable) return;
44+
45+
const poll = async () => {
46+
try {
47+
const [statusRes, downloadsRes] = await Promise.all([
48+
openhumanLocalAiStatus(),
49+
openhumanLocalAiDownloadsProgress(),
50+
]);
51+
if (statusRes.result) setStatus(statusRes.result);
52+
if (downloadsRes.result) setDownloads(downloadsRes.result);
53+
} catch {
54+
// Silently ignore — core may not be ready
55+
}
56+
};
57+
58+
void poll();
59+
timerRef.current = setInterval(poll, POLL_INTERVAL);
60+
return () => clearInterval(timerRef.current);
61+
}, [tauriAvailable]);
62+
63+
const isDownloading =
64+
status?.state === 'downloading' ||
65+
downloads?.state === 'downloading' ||
66+
(downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1);
67+
68+
// Auto-show when a new download starts: track prior state in a ref and
69+
// reset dismissed on the transition edge (not-downloading → downloading).
70+
const wasDownloadingRef = useRef(false);
71+
if (isDownloading && !wasDownloadingRef.current && dismissed) {
72+
setDismissed(false);
73+
}
74+
wasDownloadingRef.current = !!isDownloading;
75+
76+
const handleDismiss = useCallback(() => setDismissed(true), []);
77+
const handleToggleCollapse = useCallback(() => setCollapsed(prev => !prev), []);
78+
79+
if (!tauriAvailable || !isDownloading || dismissed) return null;
80+
81+
const progress = progressFromDownloads(downloads);
82+
const percent = progress != null ? Math.round(progress * 100) : null;
83+
const speed = downloads?.speed_bps;
84+
const eta = downloads?.eta_seconds;
85+
const downloaded = downloads?.downloaded_bytes;
86+
const total = downloads?.total_bytes;
87+
const label = statusLabel(downloads?.state ?? status?.state ?? 'downloading');
88+
89+
// Collapsed: small pill
90+
if (collapsed) {
91+
return createPortal(
92+
<div className="fixed bottom-4 left-4 z-[9998] animate-fade-up">
93+
<button
94+
onClick={handleToggleCollapse}
95+
className="flex items-center gap-2 bg-stone-900 border border-stone-700/50 rounded-full px-3 py-2 shadow-large hover:border-stone-600 transition-colors"
96+
aria-label="Expand download progress">
97+
<svg
98+
className="w-4 h-4 text-primary-400 animate-pulse"
99+
viewBox="0 0 20 20"
100+
fill="currentColor">
101+
<path d="M10.75 2.75a.75.75 0 00-1.5 0v8.614L6.295 8.235a.75.75 0 10-1.09 1.03l4.25 4.5a.75.75 0 001.09 0l4.25-4.5a.75.75 0 00-1.09-1.03l-2.955 3.129V2.75z" />
102+
<path d="M3.5 12.75a.75.75 0 00-1.5 0v2.5A2.75 2.75 0 004.75 18h10.5A2.75 2.75 0 0018 15.25v-2.5a.75.75 0 00-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5z" />
103+
</svg>
104+
<span className="text-xs font-medium text-stone-300">
105+
{percent != null ? `${percent}%` : label}
106+
</span>
107+
</button>
108+
</div>,
109+
document.body
110+
);
111+
}
112+
113+
// Expanded: full snackbar
114+
return createPortal(
115+
<div className="fixed bottom-4 left-4 z-[9998] w-[320px] animate-fade-up">
116+
<div className="bg-stone-900 border border-stone-700/50 rounded-2xl shadow-large overflow-hidden">
117+
{/* Header */}
118+
<div className="flex items-center justify-between px-4 pt-3 pb-1">
119+
<div className="flex items-center gap-2">
120+
<svg
121+
className="w-4 h-4 text-primary-400 animate-pulse"
122+
viewBox="0 0 20 20"
123+
fill="currentColor">
124+
<path d="M10.75 2.75a.75.75 0 00-1.5 0v8.614L6.295 8.235a.75.75 0 10-1.09 1.03l4.25 4.5a.75.75 0 001.09 0l4.25-4.5a.75.75 0 00-1.09-1.03l-2.955 3.129V2.75z" />
125+
<path d="M3.5 12.75a.75.75 0 00-1.5 0v2.5A2.75 2.75 0 004.75 18h10.5A2.75 2.75 0 0018 15.25v-2.5a.75.75 0 00-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5z" />
126+
</svg>
127+
<span className="text-sm font-medium text-white">{label}</span>
128+
</div>
129+
<div className="flex items-center gap-1">
130+
<button
131+
onClick={handleToggleCollapse}
132+
className="p-1 text-stone-500 hover:text-stone-300 transition-colors"
133+
aria-label="Collapse download progress">
134+
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
135+
<path d="M3.75 7.25a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z" />
136+
</svg>
137+
</button>
138+
<button
139+
onClick={handleDismiss}
140+
className="p-1 text-stone-500 hover:text-stone-300 transition-colors"
141+
aria-label="Dismiss download notification">
142+
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
143+
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
144+
</svg>
145+
</button>
146+
</div>
147+
</div>
148+
149+
{/* Progress bar */}
150+
<div className="px-4 py-2">
151+
<div className="h-1.5 w-full rounded-full bg-stone-800 overflow-hidden">
152+
<div
153+
className="h-full rounded-full bg-gradient-to-r from-primary-500 to-primary-400 transition-all duration-500"
154+
style={{ width: `${percent ?? 0}%` }}
155+
/>
156+
</div>
157+
</div>
158+
159+
{/* Details */}
160+
<div className="flex items-center justify-between px-4 pb-3 text-xs text-stone-400">
161+
<span>
162+
{downloaded != null && total != null
163+
? `${formatBytes(downloaded)} / ${formatBytes(total)}`
164+
: percent != null
165+
? `${percent}%`
166+
: 'Preparing...'}
167+
</span>
168+
<span>
169+
{speed != null && speed > 0 ? `${formatBytes(speed)}/s` : ''}
170+
{eta != null && eta > 0 ? ` · ${formatEta(eta)}` : ''}
171+
</span>
172+
</div>
173+
</div>
174+
</div>,
175+
document.body
176+
);
177+
};
178+
179+
export default LocalAIDownloadSnackbar;

‎app/src/components/OnboardingOverlay.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useState } from 'react';
22
import { createPortal } from 'react-dom';
33

44
import Onboarding from '../pages/onboarding/Onboarding';
5-
import { selectIsOnboarded } from '../store/authSelectors';
6-
import { useAppSelector } from '../store/hooks';
5+
import { selectIsOnboarded, selectOnboardingDeferred } from '../store/authSelectors';
6+
import { setOnboardingDeferredForUser } from '../store/authSlice';
7+
import { useAppDispatch, useAppSelector } from '../store/hooks';
78
import { DEV_FORCE_ONBOARDING } from '../utils/config';
89
import {
910
DEFAULT_WORKSPACE_ONBOARDING_FLAG,
@@ -18,10 +19,12 @@ import {
1819
* Waits for the user profile to load before making a decision.
1920
*/
2021
const OnboardingOverlay = () => {
22+
const dispatch = useAppDispatch();
2123
const token = useAppSelector(state => state.auth.token);
2224
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
2325
const user = useAppSelector(state => state.user.user);
2426
const isOnboarded = useAppSelector(selectIsOnboarded);
27+
const isDeferred = useAppSelector(selectOnboardingDeferred);
2528
const [hasWorkspaceFlag, setHasWorkspaceFlag] = useState<boolean | null>(null);
2629
const [dismissed, setDismissed] = useState(false);
2730

@@ -50,6 +53,13 @@ const OnboardingOverlay = () => {
5053
setDismissed(true);
5154
}, []);
5255

56+
const handleDefer = useCallback(() => {
57+
if (user?._id) {
58+
dispatch(setOnboardingDeferredForUser({ userId: user._id, deferred: true }));
59+
}
60+
setDismissed(true);
61+
}, [dispatch, user]);
62+
5363
// Don't show if not logged in, bootstrap not complete, or user not loaded
5464
if (!token || !isAuthBootstrapComplete || !user?._id) return null;
5565

@@ -59,13 +69,13 @@ const OnboardingOverlay = () => {
5969
// Determine if onboarding should show
6070
const shouldShow = DEV_FORCE_ONBOARDING
6171
? !dismissed
62-
: !isOnboarded && !hasWorkspaceFlag && !dismissed;
72+
: !isOnboarded && !hasWorkspaceFlag && !isDeferred && !dismissed;
6373

6474
if (!shouldShow) return null;
6575

6676
return createPortal(
6777
<div className="fixed inset-0 z-[9999] bg-canvas-900/95 backdrop-blur-md flex items-center justify-center">
68-
<Onboarding onComplete={handleComplete} />
78+
<Onboarding onComplete={handleComplete} onDefer={handleDefer} />
6979
</div>,
7080
document.body
7181
);

‎app/src/components/SetupBanner.tsx‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useCallback, useState } from 'react';
2+
3+
import { selectIsOnboarded, selectOnboardingDeferred } from '../store/authSelectors';
4+
import { setOnboardingDeferredForUser } from '../store/authSlice';
5+
import { useAppDispatch, useAppSelector } from '../store/hooks';
6+
7+
const SESSION_KEY = 'setupBannerDismissed';
8+
9+
/**
10+
* Non-intrusive banner shown when a user has deferred onboarding but hasn't completed it.
11+
* Provides a clear path to resume setup without blocking the app.
12+
*/
13+
const SetupBanner = () => {
14+
const dispatch = useAppDispatch();
15+
const isOnboarded = useAppSelector(selectIsOnboarded);
16+
const isDeferred = useAppSelector(selectOnboardingDeferred);
17+
const userId = useAppSelector(state => state.user.user?._id);
18+
19+
const [sessionDismissed, setSessionDismissed] = useState(
20+
() => sessionStorage.getItem(SESSION_KEY) === 'true'
21+
);
22+
23+
const handleResume = useCallback(() => {
24+
if (userId) {
25+
dispatch(setOnboardingDeferredForUser({ userId, deferred: false }));
26+
}
27+
}, [dispatch, userId]);
28+
29+
const handleDismiss = useCallback(() => {
30+
sessionStorage.setItem(SESSION_KEY, 'true');
31+
setSessionDismissed(true);
32+
}, []);
33+
34+
if (!isDeferred || isOnboarded || sessionDismissed || !userId) return null;
35+
36+
return (
37+
<div className="mx-4 mt-3 mb-1 flex items-center justify-between gap-3 rounded-xl border border-primary-500/20 bg-primary-500/5 px-4 py-2.5">
38+
<div className="flex items-center gap-2.5 min-w-0">
39+
<svg
40+
className="w-4 h-4 text-primary-400 flex-shrink-0"
41+
viewBox="0 0 20 20"
42+
fill="currentColor">
43+
<path
44+
fillRule="evenodd"
45+
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
46+
clipRule="evenodd"
47+
/>
48+
</svg>
49+
<span className="text-sm text-stone-300 truncate">Finish setting up OpenHuman</span>
50+
</div>
51+
<div className="flex items-center gap-2 flex-shrink-0">
52+
<button
53+
onClick={handleResume}
54+
className="text-xs font-medium text-primary-400 hover:text-primary-300 transition-colors">
55+
Continue Setup
56+
</button>
57+
<button
58+
onClick={handleDismiss}
59+
className="p-0.5 text-stone-500 hover:text-stone-300 transition-colors"
60+
aria-label="Dismiss setup banner">
61+
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
62+
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
63+
</svg>
64+
</button>
65+
</div>
66+
</div>
67+
);
68+
};
69+
70+
export default SetupBanner;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { renderWithProviders } from '../../test/test-utils';
5+
import LocalAIDownloadSnackbar from '../LocalAIDownloadSnackbar';
6+
7+
// Default: isTauri returns false, so snackbar should not render
8+
vi.mock('../../utils/tauriCommands', () => ({
9+
isTauri: vi.fn(() => false),
10+
openhumanLocalAiStatus: vi.fn().mockResolvedValue({ result: null }),
11+
openhumanLocalAiDownloadsProgress: vi.fn().mockResolvedValue({ result: null }),
12+
}));
13+
14+
describe('LocalAIDownloadSnackbar', () => {
15+
it('does not render when not in Tauri environment', () => {
16+
renderWithProviders(<LocalAIDownloadSnackbar />);
17+
18+
expect(screen.queryByText('Downloading')).not.toBeInTheDocument();
19+
expect(screen.queryByLabelText('Dismiss download notification')).not.toBeInTheDocument();
20+
});
21+
22+
it('does not render when no download is active', async () => {
23+
const tauriCommands = await import('../../utils/tauriCommands');
24+
vi.mocked(tauriCommands.isTauri).mockReturnValue(true);
25+
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
26+
result: { state: 'ready' } as never,
27+
logs: [],
28+
});
29+
vi.mocked(tauriCommands.openhumanLocalAiDownloadsProgress).mockResolvedValue({
30+
result: { state: 'idle', progress: null } as never,
31+
logs: [],
32+
});
33+
34+
renderWithProviders(<LocalAIDownloadSnackbar />);
35+
36+
// Wait for poll cycle
37+
await vi.waitFor(() => {
38+
expect(screen.queryByText('Downloading')).not.toBeInTheDocument();
39+
});
40+
41+
// Reset mock
42+
vi.mocked(tauriCommands.isTauri).mockReturnValue(false);
43+
});
44+
});

0 commit comments

Comments
 (0)