fix(onboarding): remove Screen & Accessibility Permissions step (#557)

* fix(onboarding): remove Screen & Accessibility Permissions step

The permissions step added friction to onboarding without being essential —
users can still configure screen intelligence permissions later via Settings.

Steps are now: Welcome → Referral → Skills → Context Gathering.

* fix(onboarding): remove Screen & Accessibility Permissions step

The permissions step added friction to onboarding without being essential —
users can still configure screen intelligence permissions later via Settings.

Steps are now: Welcome → Referral → Skills → Context Gathering.

* fix(onboarding): skip context gathering when no sources connected

When the user clicks "Skip for Now" on the Gmail step, complete
onboarding immediately instead of showing the context gathering step.

* fix(onboarding): address CodeRabbit review feedback

- Preserve accessibilityPermissionGranted from existing state instead
  of hardcoding false (matches ToolsPanel defensive pattern)
- Update E2E test step comments and detection logic to match the real
  onboarding flow (WelcomeStep → ReferralApplyStep → SkillsStep →
  ContextGatheringStep) and use actual UI text
This commit is contained in:
Cyrus Gray
2026-04-14 13:44:02 +05:30
committed by GitHub
parent d5be45d42d
commit 21e4a8b307
4 changed files with 38 additions and 348 deletions
+16 -21
View File
@@ -6,7 +6,6 @@ import { userApi } from '../../services/api/userApi';
import { getDefaultEnabledTools } from '../../utils/toolDefinitions';
import ContextGatheringStep from './steps/ContextGatheringStep';
import ReferralApplyStep from './steps/ReferralApplyStep';
import ScreenPermissionsStep from './steps/ScreenPermissionsStep';
import SkillsStep from './steps/SkillsStep';
import WelcomeStep from './steps/WelcomeStep';
@@ -16,7 +15,6 @@ interface OnboardingProps {
}
interface OnboardingDraft {
accessibilityPermissionGranted: boolean;
connectedSources: string[];
}
@@ -29,7 +27,7 @@ function hasReferralFromProfile(
return !!(user?.referral?.invitedBy || user?.referral?.invitedByCode);
}
/** When referral is skipped, step index 1 (apply) is not shown — treat as screen permissions (2). */
/** When referral is skipped, step index 1 (apply) is not shown — treat as skills (2). */
function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean): number {
if (skipReferralStep && currentStep === 1) {
return 2;
@@ -40,10 +38,7 @@ function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean):
const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
const { setOnboardingCompletedFlag, setOnboardingTasks, snapshot } = useCoreState();
const [currentStep, setCurrentStep] = useState(0);
const [draft, setDraft] = useState<OnboardingDraft>({
accessibilityPermissionGranted: false,
connectedSources: [],
});
const [draft, setDraft] = useState<OnboardingDraft>({ connectedSources: [] });
/** Last session token for which referral stats prefetch finished (async path only). */
const [referralStatsToken, setReferralStatsToken] = useState<string | null>(null);
const [skipReferralFromStats, setSkipReferralFromStats] = useState(false);
@@ -104,7 +99,7 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
const handleNext = () => {
const logical = resolveOnboardingStep(currentStep, skipReferralStep);
if (logical < 4) {
if (logical < 3) {
setCurrentStep(logical + 1);
}
};
@@ -122,25 +117,27 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
setCurrentStep(logical - 1);
};
const handleAccessibilityNext = (accessibilityPermissionGranted: boolean) => {
setDraft(prev => ({ ...prev, accessibilityPermissionGranted }));
handleNext();
};
const handleSkillsNext = async (connectedSources: string[]) => {
console.debug('[onboarding:handleSkillsNext]', { connectedSources });
setDraft(prev => ({ ...prev, connectedSources }));
handleNext();
if (connectedSources.length === 0) {
// No sources connected — skip context gathering and finish onboarding.
await handleContextNext(connectedSources);
} else {
handleNext();
}
};
const handleContextNext = async () => {
console.debug('[onboarding:handleContextNext]', { connectedSources: draft.connectedSources });
const handleContextNext = async (connectedSourcesOverride?: string[]) => {
const sources = connectedSourcesOverride ?? draft.connectedSources;
console.debug('[onboarding:handleContextNext]', { connectedSources: sources });
await setOnboardingTasks({
accessibilityPermissionGranted: draft.accessibilityPermissionGranted,
accessibilityPermissionGranted:
snapshot.localState.onboardingTasks?.accessibilityPermissionGranted ?? false,
localModelConsentGiven: false,
localModelDownloadStarted: false,
enabledTools: getDefaultEnabledTools(),
connectedSources: draft.connectedSources,
connectedSources: sources,
updatedAtMs: Date.now(),
});
@@ -185,10 +182,8 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
/>
);
case 2:
return <ScreenPermissionsStep onNext={handleAccessibilityNext} onBack={handleBack} />;
case 3:
return <SkillsStep onNext={handleSkillsNext} onBack={handleBack} />;
case 4:
case 3:
return (
<ContextGatheringStep
connectedSources={draft.connectedSources}
@@ -1,146 +0,0 @@
import { useEffect, useState } from 'react';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import OnboardingNextButton from '../components/OnboardingNextButton';
interface ScreenPermissionsStepProps {
onNext: (accessibilityPermissionGranted: boolean) => void;
onBack?: () => void;
}
const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsStepProps) => {
const {
status,
isLoading,
isRequestingPermissions,
isRestartingCore,
lastError,
requestPermission,
refreshPermissionsWithRestart,
} = useScreenIntelligenceState({ pollMs: 2000 });
const [shouldAutoRefreshOnReturn, setShouldAutoRefreshOnReturn] = useState(false);
const accessibilityPermission = status?.permissions.accessibility ?? 'unknown';
const isGranted = accessibilityPermission === 'granted';
useEffect(() => {
if (!shouldAutoRefreshOnReturn) {
return;
}
const refreshAfterReturn = () => {
if (document.visibilityState !== 'visible' || isLoading || isRestartingCore) {
return;
}
if (isGranted) {
setShouldAutoRefreshOnReturn(false);
return;
}
setShouldAutoRefreshOnReturn(false);
void refreshPermissionsWithRestart();
};
window.addEventListener('focus', refreshAfterReturn);
document.addEventListener('visibilitychange', refreshAfterReturn);
return () => {
window.removeEventListener('focus', refreshAfterReturn);
document.removeEventListener('visibilitychange', refreshAfterReturn);
};
}, [
isGranted,
isLoading,
isRestartingCore,
refreshPermissionsWithRestart,
shouldAutoRefreshOnReturn,
]);
const handleRequestPermissions = () => {
setShouldAutoRefreshOnReturn(true);
void requestPermission('accessibility');
};
return (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="text-center mb-5">
<h1 className="text-xl font-bold mb-2 text-stone-900">
Screen & Accessibility Permissions
</h1>
<p className="text-stone-600 text-sm">
OpenHuman uses information from your screen to constantly build context about your
workflow and assist you with desktop actions.
</p>
</div>
<div className="space-y-3 mb-5">
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-3">
<p className="text-sm font-medium mb-1 text-stone-900">Complete Privacy</p>
<p className="text-xs text-stone-600">
All screenshots and accessibility information gets processed locally by your local AI
model. No data is sent to any third party or cloud.
</p>
</div>
<div className="rounded-2xl border border-stone-200 bg-white p-3">
<p className="text-xs uppercase tracking-wide text-stone-400 mb-2">
Current permission state
</p>
<div className="flex items-center justify-between">
<span className="text-sm text-stone-900">Accessibility</span>
<span
className={`text-xs px-2 py-1 rounded-md border ${
isGranted
? 'bg-sage-50 border-sage-200 text-sage-600'
: 'bg-amber-50 border-amber-200 text-amber-600'
}`}>
{accessibilityPermission}
</span>
</div>
</div>
</div>
{!isGranted ? (
<div className="space-y-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => void refreshPermissionsWithRestart()}
disabled={isRestartingCore || isLoading}
className="flex-1 py-2 text-sm font-medium rounded-xl border border-stone-200 hover:border-stone-400 text-stone-600 hover:text-stone-900 opacity-70 hover:opacity-100 transition-all disabled:opacity-40">
{isRestartingCore ? 'Restarting...' : 'Restart & Refresh'}
</button>
<button
type="button"
onClick={handleRequestPermissions}
disabled={isRequestingPermissions || isLoading}
className="btn-primary flex-1 py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
</button>
</div>
<OnboardingNextButton onClick={() => onNext(isGranted)} />
{(lastError || status?.permission_check_process_path) && (
<div className="text-xs text-stone-400 text-center px-2 space-y-1 pt-1">
{shouldAutoRefreshOnReturn ? (
<p>
After granting access in System Settings, return here and OpenHuman will refresh
automatically.
</p>
) : null}
{lastError ? <p className="text-coral-400">{lastError}</p> : null}
{status?.permission_check_process_path ? (
<p className="font-mono break-all text-stone-500">
Grant access for: {status.permission_check_process_path}
</p>
) : null}
</div>
)}
</div>
) : (
<OnboardingNextButton onClick={() => onNext(isGranted)} />
)}
</div>
);
};
export default ScreenPermissionsStep;
@@ -1,119 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type ScreenIntelligenceState,
useScreenIntelligenceState,
} from '../../../../features/screen-intelligence/useScreenIntelligenceState';
import ScreenPermissionsStep from '../ScreenPermissionsStep';
vi.mock('../../../../features/screen-intelligence/useScreenIntelligenceState', () => ({
useScreenIntelligenceState: vi.fn(),
}));
const deniedState: ScreenIntelligenceState = {
status: {
platform_supported: true,
core_process: { pid: 4242, started_at_ms: 1712700000000 },
permissions: {
screen_recording: 'unknown',
accessibility: 'denied',
input_monitoring: 'unknown',
},
features: { screen_monitoring: true },
session: {
active: false,
started_at_ms: null,
expires_at_ms: null,
remaining_ms: null,
ttl_secs: 300,
panic_hotkey: 'Cmd+Shift+.',
stop_reason: null,
frames_in_memory: 0,
last_capture_at_ms: null,
last_context: null,
vision_enabled: true,
vision_state: 'idle',
vision_queue_depth: 0,
last_vision_at_ms: null,
last_vision_summary: null,
},
config: {
enabled: true,
capture_policy: 'hybrid',
policy_mode: 'all_except_blacklist',
baseline_fps: 1,
vision_enabled: true,
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
use_vision_model: true,
keep_screenshots: false,
allowlist: [],
denylist: [],
},
denylist: [],
is_context_blocked: false,
permission_check_process_path: '/tmp/openhuman-core-x86_64-apple-darwin',
},
lastRestartSummary: null,
recentVisionSummaries: [],
captureTestResult: null,
isCaptureTestRunning: false,
isLoading: false,
isRequestingPermissions: false,
isRestartingCore: false,
isStartingSession: false,
isStoppingSession: false,
isLoadingVision: false,
isFlushingVision: false,
lastError: null,
refreshStatus: vi.fn().mockResolvedValue(null),
requestPermission: vi.fn().mockResolvedValue(null),
refreshPermissionsWithRestart: vi.fn().mockResolvedValue(null),
startSession: vi.fn().mockResolvedValue(null),
stopSession: vi.fn().mockResolvedValue(null),
refreshVision: vi.fn().mockResolvedValue([]),
flushVision: vi.fn().mockResolvedValue(undefined),
runCaptureTest: vi.fn().mockResolvedValue(undefined),
clearError: vi.fn(),
};
const originalVisibilityDescriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState');
describe('ScreenPermissionsStep', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useScreenIntelligenceState).mockReturnValue(deniedState);
});
afterEach(() => {
if (originalVisibilityDescriptor) {
Object.defineProperty(document, 'visibilityState', originalVisibilityDescriptor);
}
});
it('auto-refreshes permissions after returning from System Settings', async () => {
const onNext = vi.fn();
render(
<MemoryRouter>
<ScreenPermissionsStep onNext={onNext} />
</MemoryRouter>
);
await screen.findByText('Screen & Accessibility Permissions');
fireEvent.click(screen.getByRole('button', { name: 'Request Permissions' }));
expect(await screen.findByText(/OpenHuman will refresh automatically/i)).toBeInTheDocument();
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
fireEvent(window, new Event('focus'));
await waitFor(() => {
expect(deniedState.refreshPermissionsWithRestart).toHaveBeenCalledTimes(1);
});
});
});
+22 -62
View File
@@ -9,13 +9,11 @@
* 3. App receives JWT, dispatches to Redux authSlice
* 4. UserProvider calls GET /auth/me (mock server)
*
* Phase 2 — Onboarding steps (6 steps in Onboarding.tsx):
* Step 0: WelcomeStep — "Continue"
* Step 1: LocalAIStep — "Continue"
* Step 2: ScreenPermissions — "Continue Without Permission" or "Continue"
* Step 3: ToolsStep — "Continue"
* Step 4: SkillsStep — "Finish Setup"
* Step 5: MnemonicStep — checkbox + "Finish Setup"
* Phase 2 — Onboarding steps (4 steps in Onboarding.tsx):
* Step 0: WelcomeStep — "Continue"
* Step 1: ReferralApplyStep — "Apply code" or "Skip for now" (conditional)
* Step 2: SkillsStep — "Continue" or "Skip for Now"
* Step 3: ContextGatheringStep — "Continue" (skipped if no sources connected)
*
* Phase 3 — Completion verification:
* - App calls POST /settings/onboarding-complete (from SkillsStep)
@@ -199,12 +197,10 @@ describe('Login flow — complete with mock data (Linux)', () => {
// browser.execute() works, so we can interact with the WebView DOM.
//
// Steps in order:
// 0: WelcomeStep — "Continue" button
// 1: LocalAIStep — "Continue"
// 2: ScreenPermissions — "Continue Without Permission" or "Continue"
// 3: ToolsStep — "Continue" button
// 4: SkillsStep — "Finish Setup" button (fires onboarding-complete)
// 5: MnemonicStep — checkbox + "Finish Setup" button
// 0: WelcomeStep — "Continue" button
// 1: ReferralApplyStep — "Apply code" or "Skip for now" (conditional)
// 2: SkillsStep — "Continue" or "Skip for Now"
// 3: ContextGatheringStep — "Continue" (skipped if no sources connected)
// -----------------------------------------------------------------------
it('onboarding overlay or home page is visible', async () => {
@@ -256,41 +252,23 @@ describe('Login flow — complete with mock data (Linux)', () => {
await browser.pause(2_000);
}
// Step 1: LocalAIStep — "Continue" button
// Step 1: ReferralApplyStep — "Skip for now" (conditional, may be skipped automatically)
{
const clicked = await clickFirstMatch(['Continue'], 10_000);
if (clicked) {
console.log(`[LoginFlow] LocalAIStep: clicked "${clicked}"`);
await browser.pause(2_000);
}
}
// Step 2: ScreenPermissionsStep — click "Continue Without Permission" (no accessibility on Linux CI)
{
const clicked = await clickFirstMatch(['Continue Without Permission', 'Continue'], 10_000);
if (clicked) {
console.log(`[LoginFlow] ScreenPermissionsStep: clicked "${clicked}"`);
await browser.pause(2_000);
}
}
// Step 3: ToolsStep — click "Continue" (keep defaults)
{
const toolsVisible = await textExists('Enable Tools');
if (toolsVisible) {
const clicked = await clickFirstMatch(['Continue'], 10_000);
const referralVisible = await textExists('Referral code');
if (referralVisible) {
const clicked = await clickFirstMatch(['Skip for now', 'Continue'], 10_000);
if (clicked) {
console.log(`[LoginFlow] ToolsStep: clicked "${clicked}"`);
console.log(`[LoginFlow] ReferralApplyStep: clicked "${clicked}"`);
await browser.pause(2_000);
}
}
}
// Step 4: SkillsStep — click "Continue" (no skills connected in E2E)
// Step 2: SkillsStep — click "Skip for Now" (no skills connected in E2E)
{
const skillsVisible = await textExists('Install Skills');
const skillsVisible = await textExists('Connect Gmail');
if (skillsVisible) {
const clicked = await clickFirstMatch(['Continue'], 10_000);
const clicked = await clickFirstMatch(['Skip for Now', 'Continue'], 10_000);
if (clicked) {
console.log(`[LoginFlow] SkillsStep: clicked "${clicked}"`);
await browser.pause(3_000);
@@ -298,31 +276,13 @@ describe('Login flow — complete with mock data (Linux)', () => {
}
}
// Step 5: MnemonicStep — tick the checkbox and click "Finish Setup"
// Step 3: ContextGatheringStep — click "Continue" (skipped when no sources connected)
{
const mnemonicVisible = await textExists('Your Recovery Phrase');
if (mnemonicVisible) {
console.log('[LoginFlow] MnemonicStep: visible');
// Tick the "I have saved my recovery phrase" checkbox
try {
const checked = await browser.execute(() => {
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (checkbox && !checkbox.checked) {
checkbox.click();
return true;
}
return checkbox?.checked ?? false;
});
console.log(`[LoginFlow] MnemonicStep: checkbox checked=${checked}`);
} catch (err) {
console.log('[LoginFlow] MnemonicStep: checkbox click failed:', err);
}
await browser.pause(1_000);
const clicked = await clickFirstMatch(['Finish Setup'], 10_000);
const contextVisible = await textExists('Preparing Your Context');
if (contextVisible) {
const clicked = await clickFirstMatch(['Continue'], 10_000);
if (clicked) {
console.log(`[LoginFlow] MnemonicStep: clicked "${clicked}"`);
console.log(`[LoginFlow] ContextGatheringStep: clicked "${clicked}"`);
await browser.pause(3_000);
}
}