From 2e7d1946b06e7a9610f89b38ce73fd95ec46a46d Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:03:48 +0530 Subject: [PATCH] fix(ui): state-aware bootstrap buttons with user feedback (#353) (#426) * fix(ui): state-aware bootstrap buttons with user feedback (#353) When local AI state is "ready", replace the Bootstrap button with a "Running" badge so clicking it no longer appears to do nothing. Show "Retry" label when state is degraded. Add transient success/error messages after manual bootstrap/re-bootstrap actions so the user always gets clear feedback. Closes #353 Co-Authored-By: Claude Opus 4.6 * test(ui): add unit tests for state-aware bootstrap buttons (#353) Cover the four key rendering states of the Home local-AI card: - "Running" badge when state is ready (Bootstrap button hidden) - "Retry" label when state is degraded - "Bootstrap" label when state is idle - Transient "Re-bootstrap complete" message after successful re-bootstrap Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../settings/panels/LocalModelPanel.tsx | 48 +++++- app/src/pages/Home.tsx | 49 +++++- .../__tests__/HomeBootstrapButtons.test.tsx | 152 ++++++++++++++++++ 3 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 app/src/pages/__tests__/HomeBootstrapButtons.test.tsx diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx index 7a0e79922..6af988f2b 100644 --- a/app/src/components/settings/panels/LocalModelPanel.tsx +++ b/app/src/components/settings/panels/LocalModelPanel.tsx @@ -63,6 +63,7 @@ const LocalModelPanel = () => { const [downloads, setDownloads] = useState(null); const [statusError, setStatusError] = useState(''); const [isTriggeringDownload, setIsTriggeringDownload] = useState(false); + const [bootstrapMessage, setBootstrapMessage] = useState(''); const [assetDownloadBusy, setAssetDownloadBusy] = useState>({}); const [summaryInput, setSummaryInput] = useState(''); @@ -199,10 +200,16 @@ const LocalModelPanel = () => { const triggerDownload = async (force: boolean) => { setIsTriggeringDownload(true); setStatusError(''); + setBootstrapMessage(''); try { await openhumanLocalAiDownload(force); await openhumanLocalAiDownloadAllAssets(force); - await loadStatus(); + const freshStatus = await openhumanLocalAiStatus(); + setStatus(freshStatus.result); + if (freshStatus.result?.state === 'ready') { + setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Models verified'); + } + setTimeout(() => setBootstrapMessage(''), 3000); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to trigger local model bootstrap'; @@ -674,18 +681,43 @@ const LocalModelPanel = () => {
- + {status?.state === 'ready' ? ( + + + + + Running + + ) : ( + + )} + {bootstrapMessage && ( + {bootstrapMessage} + )}
diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx index 925c54a47..45f9f4f0a 100644 --- a/app/src/pages/Home.tsx +++ b/app/src/pages/Home.tsx @@ -17,6 +17,7 @@ const Home = () => { const userName = user?.firstName || 'User'; const [localAiStatus, setLocalAiStatus] = useState(null); const [downloadBusy, setDownloadBusy] = useState(false); + const [bootstrapMessage, setBootstrapMessage] = useState(''); const autoRetryDoneRef = useRef(false); const initialBootstrapHandledRef = useRef(false); const initialBootstrapInFlightRef = useRef(false); @@ -44,14 +45,23 @@ const Home = () => { const runManualBootstrap = async (force: boolean) => { setDownloadBusy(true); + setBootstrapMessage(''); try { await bootstrapLocalAiWithRecommendedPreset( force, force ? '[Home re-bootstrap]' : '[Home manual bootstrap]' ); - await refreshLocalAiStatus(); + 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); } @@ -314,18 +324,41 @@ const Home = () => { )}
- + {localAiStatus?.state === 'ready' ? ( + + + + + Running + + ) : ( + + )} + {bootstrapMessage && ( + + {bootstrapMessage} + + )}
)} diff --git a/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx b/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx new file mode 100644 index 000000000..235d48553 --- /dev/null +++ b/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx @@ -0,0 +1,152 @@ +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(), +})); + +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(); + }); + }); +});