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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-04-08 20:03:48 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent e8d27a006f
commit 2e7d1946b0
3 changed files with 233 additions and 16 deletions
@@ -63,6 +63,7 @@ const LocalModelPanel = () => {
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
const [statusError, setStatusError] = useState<string>('');
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
const [assetDownloadBusy, setAssetDownloadBusy] = useState<Record<string, boolean>>({});
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 = () => {
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => void triggerDownload(false)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{isTriggeringDownload ? 'Triggering...' : 'Bootstrap / Resume'}
</button>
{status?.state === 'ready' ? (
<span className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded-md bg-green-50 text-green-700 border border-green-200 font-medium">
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Running
</span>
) : (
<button
onClick={() => void triggerDownload(false)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{isTriggeringDownload
? 'Triggering...'
: status?.state === 'degraded'
? 'Retry Bootstrap'
: 'Bootstrap / Resume'}
</button>
)}
<button
onClick={() => void triggerDownload(true)}
disabled={isTriggeringDownload}
className="px-3 py-1.5 text-xs rounded-md border border-stone-200 hover:border-stone-300 disabled:opacity-60 text-stone-600">
Force Re-bootstrap
{isTriggeringDownload ? 'Working...' : 'Force Re-bootstrap'}
</button>
{bootstrapMessage && (
<span className="text-xs text-green-600">{bootstrapMessage}</span>
)}
</div>
</div>
</section>
+41 -8
View File
@@ -17,6 +17,7 @@ const Home = () => {
const userName = user?.firstName || 'User';
const [localAiStatus, setLocalAiStatus] = useState<LocalAiStatus | null>(null);
const [downloadBusy, setDownloadBusy] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
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 = () => {
)}
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => void runManualBootstrap(false)}
disabled={downloadBusy}
className="rounded-md bg-primary-500 px-2.5 py-1.5 text-[11px] font-medium text-white hover:bg-primary-600 disabled:opacity-60">
{downloadBusy ? 'Working...' : 'Bootstrap'}
</button>
{localAiStatus?.state === 'ready' ? (
<span className="inline-flex items-center gap-1 rounded-md bg-green-50 px-2.5 py-1.5 text-[11px] font-medium text-green-700 border border-green-200">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Running
</span>
) : (
<button
onClick={() => void runManualBootstrap(false)}
disabled={downloadBusy}
className="rounded-md bg-primary-500 px-2.5 py-1.5 text-[11px] font-medium text-white hover:bg-primary-600 disabled:opacity-60">
{downloadBusy
? 'Working...'
: localAiStatus?.state === 'degraded'
? 'Retry'
: 'Bootstrap'}
</button>
)}
<button
onClick={() => void runManualBootstrap(true)}
disabled={downloadBusy}
className="rounded-md border border-stone-200 px-2.5 py-1.5 text-[11px] font-medium text-stone-600 hover:border-stone-300 disabled:opacity-60">
Re-bootstrap
{downloadBusy ? 'Working...' : 'Re-bootstrap'}
</button>
{bootstrapMessage && (
<span className="text-[11px] text-green-600 animate-fade-up">
{bootstrapMessage}
</span>
)}
</div>
</div>
)}
@@ -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: () => <div>Connection Indicator</div>,
}));
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(<Home />);
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(<Home />);
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(<Home />);
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(<Home />);
await waitFor(() => {
expect(screen.getByText('Running')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Re-bootstrap' }));
await waitFor(() => {
expect(screen.getByText('Re-bootstrap complete')).toBeInTheDocument();
});
});
});