Files
openhuman/app/src/providers/CoreStateProvider.tsx
T
Steven EnamakelandGitHub 6410db1fad feat(thu-fullrun): overlay attention, skills sync, credits & settings refresh (#479)
* Update Conversations component to enhance user messaging for budget limits. Changed the warning text for exhausted weekly inference budget to improve clarity and user experience.

* feat(schemas): add new configuration option for vision model usage

- Introduced a new optional boolean field `use_vision_model` in the schemas for enabling vision LLM for screenshot analysis.
- Updated the screen intelligence schemas to include a required `consent` field for starting sessions, replacing the previous `sample_interval_ms` field.
- Enhanced the `ttl_secs` field description for clarity and modified the `capture_policy` to `screen_monitoring` for better understanding of its purpose.

* feat(CoreStateProvider): enhance state management with optimistic updates and error handling

- Implemented optimistic local commits for `setAnalyticsEnabled` and `setOnboardingCompletedFlag` to provide instant UI feedback while ensuring state consistency through authoritative snapshot refreshes.
- Added error handling for the `refresh` function calls in `setAnalyticsEnabled`, `setOnboardingCompletedFlag`, and `clearSession` to log failures, improving robustness in state management during user interactions.
- Updated dependencies in the `useCallback` hooks to include `refresh`, ensuring proper state updates and synchronization with the core.

* feat(paths): centralize runtime path resolution for user-scoped skills data

- Introduced a new module `paths.rs` to handle the resolution of runtime paths for skills, ensuring that `skills_data` and `workspace` directories are scoped per user.
- Updated `bootstrap_skill_runtime` and `bootstrap_skills_runtime` functions to utilize the new path resolution logic, improving consistency and clarity in directory management.
- Enhanced error logging for directory creation failures to include the specific path that failed, aiding in debugging.
- Added a new optional field `overlay_ttl_ms` in the autocomplete schemas to support overlay time-to-live configuration.

* feat(ScreenIntelligencePanel): optimize config synchronization to prevent user edit clobbering

- Introduced a reference to track the last synced configuration signature, ensuring that user edits are preserved during periodic updates from the CoreStateProvider.
- Updated the effect to compare serialized configuration values, allowing for re-sync only when actual changes occur, enhancing user experience and preventing unintended data loss.

* feat(SkillManager): implement initial sync after OAuth completion

- Added functionality to trigger an initial data sync immediately after OAuth completion, ensuring users see fresh data without waiting for the next scheduled sync.
- Updated comments to clarify the change in sync behavior due to recent modifications in the Rust core, which no longer auto-triggers sync on OAuth completion.

* fix(UsageLimitModal, Conversations): enhance user messaging for budget limits

- Updated warning messages in both UsageLimitModal and Conversations components to provide clearer information regarding weekly limits and reset times.
- Improved clarity in user notifications to enhance overall experience when budget limits are reached.

* refactor(SkillSetupModal): improve session mode handling for skill configuration

- Updated the SkillSetupModal component to lock the mode at mount time, ensuring users remain in the setup wizard during their session even if the skill is marked as complete.
- Simplified mode management by replacing the forceSetup state with a sessionMode state, allowing explicit mode switching while maintaining a consistent user experience.

* feat(SkillManager): enhance setup flow for OAuth-based skills

- Updated the `startSetup` method to handle OAuth-based skills more effectively by implementing a fallback to core RPC for skills without a frontend runtime.
- Improved error handling to treat missing `onSetupStart` implementations as successful completion for pure OAuth skills, allowing the setup wizard to display the "Connected!" screen.
- Added detailed logging for both local runtime and core RPC fallback scenarios to improve traceability during the setup process.

* feat(Home): enhance local AI status handling and asset management

- Introduced a new state for local AI assets, allowing for better tracking of model file readiness.
- Updated the loading logic to fetch both local AI status and assets concurrently, improving performance and error handling.
- Implemented a mechanism to hide the Local Model Runtime card once all models are fully downloaded, enhancing user experience.
- Added comprehensive comments to clarify the logic behind model readiness checks based on asset states.

* refactor(Credits): update credit balance structure and terminology

- Renamed credit categories in the RewardsCouponSection and PayAsYouGoCard components for clarity, changing "General credits" to "Promo credits" and "Top-up credits" to "Team top-up."
- Updated the credit balance API to reflect the new structure, replacing `balanceUsd` and `topUpBalanceUsd` with `promotionBalanceUsd` and `teamTopupUsd`.
- Adjusted normalization logic in the credits API to accommodate the new credit balance fields.
- Modified tests to ensure correct handling of the updated credit balance structure.

* feat(Settings): reorganize billing settings and update descriptions

- Added a new top-level billing section to the settings, promoting it out of the Account & Security category for better visibility.
- Updated the description for the Account & Security section to remove billing references, focusing on recovery phrase, team management, and linked account access.
- Adjusted the settings navigation to accommodate the new billing section, ensuring proper routing and user experience.

* refactor(Config): change logging level from info to debug for environment overrides

- Updated logging statements in the Config implementation to use debug level instead of info, reducing verbosity during runtime while maintaining necessary traceability for configuration loading.

* feat(Overlay): implement overlay attention event handling and refactor overlay app structure

- Introduced a new overlay module to manage attention events, allowing the core to publish messages to the overlay window.
- Enhanced the OverlayApp component to handle dictation and attention events, improving user interaction with the overlay.
- Refactored the overlay state management to support different modes (idle, stt, attention) and added auto-dismiss functionality for attention messages.
- Removed the Browser Access Toggle from the Skills page, streamlining the UI and focusing on core functionalities.
- Updated tests to reflect changes in the Skills component and removed unnecessary mocks related to browser access.

* fix(OverlayBubbleChip): reset typewriter animation on new bubble identity

- Updated the OverlayBubbleChip component to reset the typewriter animation correctly when a new bubble is displayed by using the `key` prop.
- Refactored the cleanup logic in the useEffect hook to ensure proper interval management and state reset, enhancing the user experience with bubble transitions.

* refactor(rest): streamline key_bytes_from_string function and improve readability

- Simplified the condition for checking the ASCII key length and character restrictions in the key_bytes_from_string function.
- Consolidated the import statements for base64 engines into a single line for better clarity.
- Adjusted test data formatting for improved readability in the key_bytes_from_string_tests module.

* enhance(logging): improve color detection logic for terminal output

- Updated the color detection logic in the logging module to prioritize environment variables (`NO_COLOR`, `FORCE_COLOR`, `CLICOLOR_FORCE`) for better control over color output.
- Added detailed comments explaining the color resolution order, enhancing code clarity and maintainability.

* test(Home): add mock for openhumanLocalAiAssetsStatus in tests

- Enhanced the Home and HomeBootstrapButtons test files by adding a mock implementation for openhumanLocalAiAssetsStatus, which resolves to an object with null result and empty logs. This improves the test setup for local AI asset status handling.

* refactor(SkillSetupModal): improve session mode handling and loading state

- Updated the SkillSetupModal component to ensure session mode is determined after the first snapshot resolution, preventing premature defaults to the setup wizard.
- Introduced a loading state to display a message while waiting for the skill setup status, enhancing user experience during the modal's initial render.
- Refactored the SkillManager to throw errors for real failures during setup, ensuring proper error handling and user feedback.

* refactor(Config): simplify logging for invalid proxy scope values

- Consolidated the logging statement for invalid OPENHUMAN_PROXY_SCOPE values into a single line, improving code readability while maintaining the warning functionality.
2026-04-09 23:01:56 -07:00

400 lines
12 KiB
TypeScript

import debugFactory from 'debug';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
type CoreAppSnapshot,
type CoreOnboardingTasks,
type CoreState,
getCoreStateSnapshot,
setCoreStateSnapshot,
} from '../lib/coreState/store';
import { syncAnalyticsConsent } from '../services/analytics';
import {
fetchCoreAppSnapshot,
getTeamInvites,
getTeamMembers,
listTeams,
updateCoreLocalState,
} from '../services/coreStateApi';
import {
openhumanUpdateAnalyticsSettings,
setOnboardingCompleted,
storeSession,
syncMemoryClientToken,
logout as tauriLogout,
} from '../utils/tauriCommands';
const log = debugFactory('core-state');
const POLL_MS = 2000;
const MAX_BOOTSTRAP_RETRIES = 5;
/** Extract only non-sensitive fields from an RPC/fetch error. */
function sanitizeError(error: unknown): { message?: string; code?: string; status?: number } {
if (error instanceof Error) {
return { message: error.message };
}
if (error && typeof error === 'object') {
const e = error as Record<string, unknown>;
return {
message: typeof e.message === 'string' ? e.message : undefined,
code: typeof e.code === 'string' ? e.code : undefined,
status: typeof e.status === 'number' ? e.status : undefined,
};
}
return { message: String(error) };
}
interface CoreStateContextValue extends CoreState {
refresh: () => Promise<void>;
refreshTeams: () => Promise<void>;
refreshTeamMembers: (teamId: string) => Promise<void>;
refreshTeamInvites: (teamId: string) => Promise<void>;
setAnalyticsEnabled: (enabled: boolean) => Promise<void>;
setOnboardingCompletedFlag: (value: boolean) => Promise<void>;
setEncryptionKey: (value: string | null) => Promise<void>;
setPrimaryWalletAddress: (value: string | null) => Promise<void>;
setOnboardingTasks: (value: CoreOnboardingTasks | null) => Promise<void>;
storeSessionToken: (token: string, user?: object) => Promise<void>;
clearSession: () => Promise<void>;
}
const CoreStateContext = createContext<CoreStateContextValue | null>(null);
function snapshotIdentity(snapshot: CoreAppSnapshot): string | null {
return snapshot.auth.userId ?? snapshot.currentUser?._id ?? null;
}
function normalizeSnapshot(
result: Awaited<ReturnType<typeof fetchCoreAppSnapshot>>
): CoreAppSnapshot {
return {
auth: result.auth,
sessionToken: result.sessionToken,
currentUser: result.currentUser,
onboardingCompleted: result.onboardingCompleted,
analyticsEnabled: result.analyticsEnabled,
localState: {
encryptionKey: result.localState.encryptionKey ?? null,
primaryWalletAddress: result.localState.primaryWalletAddress ?? null,
onboardingTasks: result.localState.onboardingTasks ?? null,
},
runtime: {
screenIntelligence: result.runtime?.screenIntelligence ?? null,
localAi: result.runtime?.localAi ?? null,
autocomplete: result.runtime?.autocomplete ?? null,
service: result.runtime?.service ?? null,
},
};
}
export default function CoreStateProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<CoreState>(() => getCoreStateSnapshot());
const snapshotRequestIdRef = useRef(0);
const teamsRequestIdRef = useRef(0);
const memoryTokenRef = useRef<string | null>(state.snapshot.sessionToken);
const bootstrapFailCountRef = useRef(0);
const refreshInFlightRef = useRef<Promise<void> | null>(null);
const commitState = useCallback((updater: (previous: CoreState) => CoreState) => {
setState(previous => {
const next = updater(previous);
setCoreStateSnapshot(next);
return next;
});
}, []);
const refreshCore = useCallback(async () => {
const requestId = ++snapshotRequestIdRef.current;
const snapshot = normalizeSnapshot(await fetchCoreAppSnapshot());
commitState(previous => {
if (requestId !== snapshotRequestIdRef.current) {
return previous;
}
const previousIdentity = snapshotIdentity(previous.snapshot);
const nextIdentity = snapshotIdentity(snapshot);
const shouldClearScopedCaches =
previousIdentity !== nextIdentity ||
(previous.snapshot.auth.isAuthenticated && !snapshot.auth.isAuthenticated);
return {
...previous,
isBootstrapping: false,
isReady: true,
snapshot,
teams: shouldClearScopedCaches ? [] : previous.teams,
teamMembersById: shouldClearScopedCaches ? {} : previous.teamMembersById,
teamInvitesById: shouldClearScopedCaches ? {} : previous.teamInvitesById,
};
});
syncAnalyticsConsent(snapshot.analyticsEnabled);
if (!snapshot.sessionToken) {
memoryTokenRef.current = null;
return;
}
if (memoryTokenRef.current !== snapshot.sessionToken) {
try {
await syncMemoryClientToken(snapshot.sessionToken);
memoryTokenRef.current = snapshot.sessionToken;
} catch (error) {
console.warn('[core-state] memory client sync failed during refresh:', error);
}
}
}, [commitState]);
/** Serialized refresh — all callers share the same in-flight promise. */
const refresh = useCallback(async () => {
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const promise = refreshCore().finally(() => {
refreshInFlightRef.current = null;
});
refreshInFlightRef.current = promise;
return promise;
}, [refreshCore]);
const refreshTeams = useCallback(async () => {
const requestId = ++teamsRequestIdRef.current;
const identityAtStart = snapshotIdentity(getCoreStateSnapshot().snapshot);
const teams = await listTeams();
commitState(previous => {
if (requestId !== teamsRequestIdRef.current) {
return previous;
}
if (snapshotIdentity(previous.snapshot) !== identityAtStart) {
return previous;
}
return { ...previous, teams };
});
}, [commitState]);
const refreshTeamMembers = useCallback(
async (teamId: string) => {
const members = await getTeamMembers(teamId);
commitState(previous => ({
...previous,
teamMembersById: { ...previous.teamMembersById, [teamId]: members },
}));
},
[commitState]
);
const refreshTeamInvites = useCallback(
async (teamId: string) => {
const invites = await getTeamInvites(teamId);
commitState(previous => ({
...previous,
teamInvitesById: { ...previous.teamInvitesById, [teamId]: invites },
}));
},
[commitState]
);
useEffect(() => {
let cancelled = false;
const doRefresh = async () => {
try {
await refresh();
bootstrapFailCountRef.current = 0;
} catch (error) {
if (!cancelled) {
bootstrapFailCountRef.current += 1;
const safe = sanitizeError(error);
log(
'refresh failed attempt=%d/%d error=%O',
bootstrapFailCountRef.current,
MAX_BOOTSTRAP_RETRIES,
safe
);
console.warn(
`[core-state] poll failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`,
safe
);
if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) {
commitState(previous => {
if (previous.isBootstrapping) {
return { ...previous, isBootstrapping: false };
}
return previous;
});
}
}
}
};
const load = async () => {
await doRefresh();
if (!cancelled) {
const next = getCoreStateSnapshot();
if (next.snapshot.auth.isAuthenticated) {
await refreshTeams().catch(err => {
log('refreshTeams failed during bootstrap: %O', sanitizeError(err));
});
}
}
};
void load();
let timeoutId: number | null = null;
const scheduleNext = () => {
timeoutId = window.setTimeout(async () => {
await doRefresh();
if (!cancelled) {
scheduleNext();
}
}, POLL_MS);
};
scheduleNext();
return () => {
cancelled = true;
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
};
}, [commitState, refresh, refreshTeams]);
const setAnalyticsEnabled = useCallback(
async (enabled: boolean) => {
await openhumanUpdateAnalyticsSettings({ enabled });
// Optimistic local commit for instant UI feedback, then re-pull the
// authoritative snapshot so the frontend cache matches the core.
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, analyticsEnabled: enabled },
}));
syncAnalyticsConsent(enabled);
await refresh().catch(err => {
log('refresh failed after setAnalyticsEnabled: %O', sanitizeError(err));
});
},
[commitState, refresh]
);
const setOnboardingCompletedFlag = useCallback(
async (value: boolean) => {
await setOnboardingCompleted(value);
// Optimistic local commit for instant UI feedback, then re-pull the
// authoritative snapshot so the frontend cache matches the core.
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, onboardingCompleted: value },
}));
await refresh().catch(err => {
log('refresh failed after setOnboardingCompletedFlag: %O', sanitizeError(err));
});
},
[commitState, refresh]
);
const updateLocalState = useCallback(
async (params: Parameters<typeof updateCoreLocalState>[0]) => {
await updateCoreLocalState(params);
await refresh();
},
[refresh]
);
const storeSessionToken = useCallback(
async (token: string, user?: object) => {
await storeSession(token, user ?? {});
try {
await syncMemoryClientToken(token);
memoryTokenRef.current = token;
} catch (error) {
console.warn('[core-state] memory client sync failed after session store:', error);
}
await refresh();
await refreshTeams().catch(err => {
log('refreshTeams failed after session store: %O', sanitizeError(err));
});
},
[refresh, refreshTeams]
);
const clearSession = useCallback(async () => {
// Bump the snapshot request counter before doing anything else so that
// any snapshot poll already in flight when the user clicked logout is
// invalidated on return — otherwise its stale "authenticated" result
// would clobber our cleared state a few hundred ms after logout.
snapshotRequestIdRef.current += 1;
await tauriLogout();
// Optimistic local clear for instant UI response.
commitState(previous => ({
...previous,
teams: [],
teamMembersById: {},
teamInvitesById: {},
snapshot: {
...previous.snapshot,
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
sessionToken: null,
currentUser: null,
onboardingCompleted: false,
},
}));
memoryTokenRef.current = null;
// Re-pull the authoritative snapshot from the core so the frontend
// cache matches whatever the core now reports. This mirrors the pattern
// used by storeSessionToken and ensures any downstream consumer reading
// from the snapshot sees the post-logout state immediately.
await refresh().catch(err => {
log('refresh failed after clearSession: %O', sanitizeError(err));
});
}, [commitState, refresh]);
const value = useMemo<CoreStateContextValue>(
() => ({
...state,
refresh,
refreshTeams,
refreshTeamMembers,
refreshTeamInvites,
setAnalyticsEnabled,
setOnboardingCompletedFlag,
setEncryptionKey: value => updateLocalState({ encryptionKey: value }),
setPrimaryWalletAddress: value => updateLocalState({ primaryWalletAddress: value }),
setOnboardingTasks: value => updateLocalState({ onboardingTasks: value }),
storeSessionToken,
clearSession,
}),
[
clearSession,
refresh,
refreshTeamInvites,
refreshTeamMembers,
refreshTeams,
setAnalyticsEnabled,
setOnboardingCompletedFlag,
state,
storeSessionToken,
updateLocalState,
]
);
return <CoreStateContext.Provider value={value}>{children}</CoreStateContext.Provider>;
}
export function useCoreState() {
const context = useContext(CoreStateContext);
if (!context) {
throw new Error('useCoreState must be used within CoreStateProvider');
}
return context;
}