mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
* 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:
co-authored by
Claude Opus 4.6
parent
e8d27a006f
commit
2e7d1946b0
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user