diff --git a/apps/desktop/src/api/local-models.test.ts b/apps/desktop/src/api/local-models.test.ts new file mode 100644 index 0000000000..373e45d128 --- /dev/null +++ b/apps/desktop/src/api/local-models.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./client', () => ({ + hermesApi: vi.fn(), + profileScoped: vi.fn(() => ({})) +})) + +const client = await import('./client') + +const { pauseLocalDownload, resumeLocalDownload } = await import('./local-models') + +const hermesApi = vi.mocked(client.hermesApi) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('local download pause/resume API', () => { + it('pauseLocalDownload posts {job_id} to the existing pause route and returns the {ok,paused} contract', async () => { + hermesApi.mockResolvedValue({ ok: true, paused: true } as never) + + const res = await pauseLocalDownload('job-7') + + expect(hermesApi).toHaveBeenCalledTimes(1) + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + body: { job_id: 'job-7' }, + method: 'POST', + path: '/api/local-models/download/pause' + }) + expect(res).toEqual({ ok: true, paused: true }) + }) + + it('resumeLocalDownload posts {job_id} to the existing resume route and returns the {ok,resumed} contract', async () => { + hermesApi.mockResolvedValue({ ok: true, resumed: false } as never) + + const res = await resumeLocalDownload('job-8') + + expect(hermesApi).toHaveBeenCalledTimes(1) + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + body: { job_id: 'job-8' }, + method: 'POST', + path: '/api/local-models/download/resume' + }) + // A false resumed flag (no parked download) reaches the caller so the + // UI can report "couldn't resume" rather than silently claiming success. + expect(res).toEqual({ ok: true, resumed: false }) + }) + + it('a failed pause propagates as a rejection — never a silent success', async () => { + hermesApi.mockRejectedValue(new Error('unknown download job')) + + await expect(pauseLocalDownload('gone')).rejects.toThrow('unknown download job') + }) +}) diff --git a/apps/desktop/src/api/local-models.ts b/apps/desktop/src/api/local-models.ts index 39c883e75c..7a31ccda0b 100644 --- a/apps/desktop/src/api/local-models.ts +++ b/apps/desktop/src/api/local-models.ts @@ -84,6 +84,28 @@ export function getLocalModelsJobs(): Promise<{ jobs: LocalRuntimeJob[] }> { }) } +// Pause/resume a download-phase job (catalog model, quickstart, runtime +// install/update, HF-browsed). The backend answers {ok, paused} / +// {ok, resumed} — a false flag (no live download handle, e.g. a +// quickstart engine leg) is reported to the caller, not treated as success. +export function pauseLocalDownload(jobId: string): Promise<{ ok: boolean; paused: boolean }> { + return hermesApi<{ ok: boolean; paused: boolean }>({ + ...profileScoped(), + body: { job_id: jobId }, + method: 'POST', + path: '/api/local-models/download/pause' + }) +} + +export function resumeLocalDownload(jobId: string): Promise<{ ok: boolean; resumed: boolean }> { + return hermesApi<{ ok: boolean; resumed: boolean }>({ + ...profileScoped(), + body: { job_id: jobId }, + method: 'POST', + path: '/api/local-models/download/resume' + }) +} + export function activateLocalModel(modelId: string): Promise<{ job_id: string }> { return hermesApi<{ job_id: string }>({ ...profileScoped(), diff --git a/apps/desktop/src/app/settings/local-model-download-progress.test.tsx b/apps/desktop/src/app/settings/local-model-download-progress.test.tsx new file mode 100644 index 0000000000..08decada98 --- /dev/null +++ b/apps/desktop/src/app/settings/local-model-download-progress.test.tsx @@ -0,0 +1,165 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import type * as RuntimeJobs from '@/store/local-runtime-jobs' +import type * as Notifications from '@/store/notifications' +import type { LocalRuntimeJob } from '@/types/hermes' + +vi.mock('@/hermes', () => ({ + pauseLocalDownload: vi.fn(), + resumeLocalDownload: vi.fn() +})) + +vi.mock('@/store/notifications', async importOriginal => ({ + ...(await importOriginal()), + notifyError: vi.fn() +})) + +vi.mock('@/store/local-runtime-jobs', async importOriginal => ({ + ...(await importOriginal()), + watchLocalRuntimeJobs: vi.fn() +})) + +import { pauseLocalDownload, resumeLocalDownload } from '@/hermes' + +import { isDownloadPhase, LocalModelDownloadActions } from './local-model-download-progress' + +function job(overrides: Partial): LocalRuntimeJob { + return { + detail: '', + done_bytes: 40, + error: null, + job_id: 'j1', + kind: 'model-download', + model_id: 'm1', + phase: 'downloading', + status: 'running', + target: 'Qwen3.6 27B', + total_bytes: 100, + ...overrides + } +} + +function renderActions(current: LocalRuntimeJob) { + act(() => { + render( + + + + ) + }) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(pauseLocalDownload).mockResolvedValue({ ok: true, paused: true }) + vi.mocked(resumeLocalDownload).mockResolvedValue({ ok: true, resumed: true }) +}) + +afterEach(() => { + cleanup() +}) + +describe('LocalModelDownloadActions', () => { + it('running download with can_pause:true renders Pause; clicking sends pauseLocalDownload exactly once with the job id', async () => { + renderActions(job({ can_pause: true })) + + const pause = screen.getByRole('button', { name: /pause/i }) + fireEvent.click(pause) + + await waitFor(() => { + expect(pauseLocalDownload).toHaveBeenCalledTimes(1) + }) + expect(pauseLocalDownload).toHaveBeenCalledWith('j1') + expect(resumeLocalDownload).not.toHaveBeenCalled() + }) + + it('running download without explicit can_pause shows NO control (no guessing about old backends)', () => { + renderActions(job({})) + + expect(screen.queryByRole('button', { name: /pause/i })).toBeNull() + }) + + it('can_pause:false hides the control (server start / default assignment)', () => { + renderActions(job({ can_pause: false })) + + expect(screen.queryByRole('button', { name: /pause/i })).toBeNull() + }) + + it('pause_requested renders a disabled pending control, not a vanished one', () => { + renderActions(job({ can_pause: false, pause_requested: true })) + + const pending = screen.getByRole('button', { name: /pause/i }) + expect((pending as HTMLButtonElement).disabled).toBe(true) + }) + + it('paused job renders the Paused label + Resume (can_resume:true); clicking sends resumeLocalDownload with the job id', async () => { + renderActions(job({ can_resume: true, status: 'paused' })) + + expect(screen.getByText(/paused/i)).toBeTruthy() + + const resume = screen.getByRole('button', { name: /resume/i }) + fireEvent.click(resume) + + await waitFor(() => { + expect(resumeLocalDownload).toHaveBeenCalledTimes(1) + }) + expect(resumeLocalDownload).toHaveBeenCalledWith('j1') + expect(pauseLocalDownload).not.toHaveBeenCalled() + }) + + it('paused hides Resume when can_resume is not explicitly true', () => { + renderActions(job({ status: 'paused' })) + + expect(screen.getByText(/paused/i)).toBeTruthy() + expect(screen.queryByRole('button', { name: /resume/i })).toBeNull() + }) + + it('a paused:false response is a benign race — truth is re-kicked, no false failure toast', async () => { + const { notifyError } = await import('@/store/notifications') + const { watchLocalRuntimeJobs } = await import('@/store/local-runtime-jobs') + + // The job may have settled between render and click; the backend says + // nothing paused. Refresh the authoritative snapshot; do NOT toast a + // download failure the user never saw. + vi.mocked(pauseLocalDownload).mockResolvedValue({ ok: true, paused: false }) + + renderActions(job({ can_pause: true })) + fireEvent.click(screen.getByRole('button', { name: /pause/i })) + + await waitFor(() => { + expect(watchLocalRuntimeJobs).toHaveBeenCalled() + }) + expect(notifyError).not.toHaveBeenCalled() + }) + + it('a rejected pause propagates to the error path — never a silent success', async () => { + const { notifyError } = await import('@/store/notifications') + + vi.mocked(pauseLocalDownload).mockRejectedValue(new Error('unknown download job')) + + renderActions(job({ can_pause: true })) + fireEvent.click(screen.getByRole('button', { name: /pause/i })) + + await waitFor(() => { + expect(notifyError).toHaveBeenCalledTimes(1) + }) + }) + + it('no control on settled jobs', () => { + renderActions(job({ can_pause: true, status: 'done' })) + + expect(screen.queryByRole('button', { name: /pause|resume/i })).toBeNull() + }) +}) + +describe('isDownloadPhase', () => { + it('download phases cover every job kind that fetches bytes; finalization is not one', () => { + expect(isDownloadPhase(job({ kind: 'model-download' }))).toBe(true) + expect(isDownloadPhase(job({ kind: 'runtime-install', phase: 'downloading-runtime' }))).toBe(true) + expect(isDownloadPhase(job({ kind: 'quickstart', phase: 'downloading' }))).toBe(true) + expect(isDownloadPhase(job({ kind: 'quickstart', phase: 'setting-default' }))).toBe(false) + expect(isDownloadPhase(job({ kind: 'model-activate', phase: 'loading' }))).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/settings/local-model-download-progress.tsx b/apps/desktop/src/app/settings/local-model-download-progress.tsx new file mode 100644 index 0000000000..7d3e8d8418 --- /dev/null +++ b/apps/desktop/src/app/settings/local-model-download-progress.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { pauseLocalDownload, resumeLocalDownload } from '@/hermes' +import { useI18n } from '@/i18n' +import { Loader2, Pause, Play } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' +import { notifyError } from '@/store/notifications' +import type { LocalRuntimeJob } from '@/types/hermes' + +import { Pill } from './primitives' + +interface ProgressBarProps { + percent: number | undefined + paused?: boolean +} + +interface LocalModelDownloadProps { + job: LocalRuntimeJob +} + +export function ProgressBar({ percent, paused = false }: ProgressBarProps) { + const unknown = typeof percent !== 'number' + + return ( +
+
+
+ ) +} + +export function gbLabel(bytes: number | null | undefined): string { + if (bytes == null) { + return '—' + } + + return `${(bytes / (1 << 30)).toFixed(1)} GB` +} + +// Phases where bytes are actually moving (or parked mid-move). Quickstart +// recomputes percent against EACH stage's own download plan — the counter +// resets between stages by design, so it is only shown during a genuine +// download phase. Gate of last resort only: the backend's can_pause flag +// is the primary control gate. +const QUICKSTART_DOWNLOAD_PHASES = new Set([ + 'downloading', + 'downloading-runtime', + 'unpacking-runtime', + 'verifying-runtime' +]) + +export function isDownloadPhase(job: LocalRuntimeJob): boolean { + if (job.kind === 'model-download' || job.kind === 'runtime-install') { + return true + } + + return job.kind === 'quickstart' && QUICKSTART_DOWNLOAD_PHASES.has(job.phase) +} + +// Progress bar + honest byte counter. The counter is suppressed outside +// download phases (a stage hand-off would otherwise read as progress loss); +// paused rows keep the frozen counter they parked with. +export function LocalModelDownloadProgress({ job }: LocalModelDownloadProps) { + const { t } = useI18n() + const copy = t.settings.localModels + const showCounter = isDownloadPhase(job) + + return ( +
+ + +

+ {!showCounter || (!job.done_bytes && job.detail) + ? job.detail + : copy.downloadProgress(gbLabel(job.done_bytes), gbLabel(job.total_bytes))} +

+
+ ) +} + +export function LocalModelDownloadActions({ job }: LocalModelDownloadProps) { + const { t } = useI18n() + const copy = t.settings.localModels + const [busy, setBusy] = useState(false) + + const send = async (kind: 'pause' | 'resume'): Promise => { + setBusy(true) + + try { + // A false paused/resumed flag is usually a benign race (the job + // settled between render and click) — the authoritative refresh + // below decides what the row shows; only a real transport failure + // surfaces as an error. + if (kind === 'pause') { + await pauseLocalDownload(job.job_id) + } else { + await resumeLocalDownload(job.job_id) + } + + watchLocalRuntimeJobs() + } catch (err) { + notifyError(err, copy.downloadFailed(job.target)) + } finally { + setBusy(false) + } + } + + if (job.status === 'paused') { + return ( +
+ + + {copy.downloadPausedLabel} + + + {job.can_resume === true && ( + + )} +
+ ) + } + + // No gate invented client-side: can_pause is the backend's explicit + // verdict; pause_requested keeps the control visible but disabled so a + // pending request never reads as "gone". + if (job.status !== 'running' || (!job.pause_requested && job.can_pause !== true)) { + return null + } + + if (job.pause_requested) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/desktop/src/app/settings/local-models-settings.test.tsx b/apps/desktop/src/app/settings/local-models-settings.test.tsx index b779434d6c..86607c8145 100644 --- a/apps/desktop/src/app/settings/local-models-settings.test.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.test.tsx @@ -3,7 +3,7 @@ import { MemoryRouter, useLocation } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/i18n' -import { $localRuntimeJobs } from '@/store/local-runtime-jobs' +import { $localRuntimeJobs, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' import { LocalModelsSettings } from './local-models-settings' @@ -23,7 +23,9 @@ vi.mock('@/hermes', () => ({ getLocalRuntimeJob: vi.fn(), installLocalRuntime: vi.fn(), listHFRepoFiles: vi.fn(), + pauseLocalDownload: vi.fn(), quickstartLocalModels: vi.fn(), + resumeLocalDownload: vi.fn(), searchHFModels: vi.fn(), sideloadLocalModel: vi.fn() })) @@ -116,9 +118,21 @@ function renderPane() { // is one 'Configure…' click away. Render and click through. async function renderFullPane() { const result = renderPane() - const configure = await screen.findByRole('button', { name: /configure/i }) - fireEvent.click(configure) + // Wait for status to load: the pane either shows the setup card (click + // through to the full pane) or, when an active runtime/model job routes + // straight to the full pane, the runtime section directly. + await waitFor(() => { + expect( + Boolean(screen.queryByRole('button', { name: /configure/i })) || screen.queryAllByText(/this machine/i).length > 0 + ).toBe(true) + }) + + const configure = screen.queryByRole('button', { name: /configure/i }) + + if (configure) { + fireEvent.click(configure) + } return result } @@ -127,12 +141,18 @@ beforeEach(() => { mocked.getLocalModelsStatus.mockResolvedValue(BASE_STATUS) mocked.getLocalHardware.mockResolvedValue(BASE_HARDWARE) mocked.getLocalCatalog.mockResolvedValue({ models: [FITTING_MODEL, SPILLED_MODEL, REFUSED_MODEL] }) - mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] }) + // The backend mock ECHOES the atom: the watcher's immediate poll reads + // seeded jobs instead of wiping them with a default {jobs:[]}. + mocked.getLocalModelsJobs.mockImplementation(async () => ({ jobs: [...$localRuntimeJobs.get()] })) $localRuntimeJobs.set([]) }) -afterEach(() => { +afterEach(async () => { cleanup() + mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] }) + await act(async () => { + watchLocalRuntimeJobs() + }) vi.clearAllMocks() }) @@ -554,3 +574,200 @@ describe('quickstart completion navigation', () => { expect(routeProbe).toHaveBeenCalledWith('/') }) }) + +describe('pause / resume integration', () => { + beforeEach(() => { + vi.mocked(hermes.pauseLocalDownload).mockResolvedValue({ ok: true, paused: true }) + vi.mocked(hermes.resumeLocalDownload).mockResolvedValue({ ok: true, resumed: true }) + }) + + it('a running catalog download shows Pause; clicking sends the job id once', async () => { + mocked.getLocalModelsStatus.mockResolvedValue({ + ...BASE_STATUS, + runtime_installed: true, + runtime_backend: 'cuda' + }) + $localRuntimeJobs.set([ + { + job_id: 'j1', + kind: 'model-download', + target: 'Qwen3.6 27B', + model_id: FITTING_MODEL.id, + status: 'running', + phase: 'downloading', + detail: 'Qwen3.6 27B — 17.6 GB', + total_bytes: 100, + can_pause: true, + done_bytes: 40, + percent: 40, + error: null + } + ]) + + await renderFullPane() + await screen.findByText('Qwen3.6 27B') + + fireEvent.click(screen.getByRole('button', { name: /pause/i })) + + await waitFor(() => { + expect(hermes.pauseLocalDownload).toHaveBeenCalledTimes(1) + }) + expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('j1') + expect(hermes.resumeLocalDownload).not.toHaveBeenCalled() + }) + + it('a paused catalog download keeps its row: Paused label, Resume button, progress bar retained', async () => { + mocked.getLocalModelsStatus.mockResolvedValue({ + ...BASE_STATUS, + runtime_installed: true, + runtime_backend: 'cuda' + }) + $localRuntimeJobs.set([ + { + job_id: 'j1', + kind: 'model-download', + target: 'Qwen3.6 27B', + model_id: FITTING_MODEL.id, + can_resume: true, + phase: 'downloading', + status: 'paused', + detail: 'Qwen3.6 27B — 17.6 GB', + total_bytes: 100, + done_bytes: 40, + percent: 40, + error: null + } + ]) + + await renderFullPane() + await screen.findByText('Qwen3.6 27B') + + // Row survives with an honest parked label and a way forward. + expect(screen.getAllByText(/paused/i).length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: /resume/i })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /resume/i })) + await waitFor(() => { + expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('j1') + }) + + // The watcher re-kicked: an authoritative re-read happens after resume. + await waitFor(() => { + expect(mocked.getLocalModelsJobs.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + }) + + it('a paused quickstart stays pinned in the hero with a Resume control', async () => { + $localRuntimeJobs.set([ + { + job_id: 'q1', + kind: 'quickstart', + target: 'Qwen3.6 27B', + model_id: 'qwen3.6-27b', + can_resume: true, + phase: 'downloading', + status: 'paused', + detail: '', + total_bytes: 100, + done_bytes: 30, + percent: 30, + error: null + } + ]) + + renderPane() + await screen.findAllByText(/paused/i) + + expect(screen.getByRole('button', { name: /resume/i })).toBeTruthy() + // Hero is pinned — no quickstart action button competes with it. + expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /resume/i })) + await waitFor(() => { + expect(hermes.resumeLocalDownload).toHaveBeenCalledWith('q1') + }) + }) + + it('a running quickstart hero shows Pause during the download stage', async () => { + $localRuntimeJobs.set([ + { + job_id: 'q1', + kind: 'quickstart', + target: 'Qwen3.6 27B', + model_id: 'qwen3.6-27b', + can_pause: true, + phase: 'downloading', + status: 'running', + detail: '', + total_bytes: 100, + done_bytes: 30, + percent: 30, + error: null + } + ]) + + renderPane() + await screen.findByText('Qwen3.6 27B') + + expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy() + }) + + it('a running runtime install row shows Pause when the engine leg can park', async () => { + mocked.getLocalModelsStatus.mockResolvedValue({ + ...BASE_STATUS, + runtime_installed: false, + update_available: false + }) + $localRuntimeJobs.set([ + { + job_id: 'r1', + kind: 'runtime-install', + target: 'llamacpp', + model_id: null, + can_pause: true, + phase: 'downloading-runtime', + status: 'running', + detail: 'llamacpp b10290', + total_bytes: 100, + done_bytes: 10, + percent: 10, + error: null + } + ]) + + renderPane() + await screen.findByText(/installing/i) + + const pause = screen.getByRole('button', { name: /pause/i }) + fireEvent.click(pause) + + await waitFor(() => { + expect(hermes.pauseLocalDownload).toHaveBeenCalledWith('r1') + }) + }) + + it('quickstart hero suppresses the byte counter outside download phases', async () => { + $localRuntimeJobs.set([ + { + job_id: 'q1', + kind: 'quickstart', + target: 'Qwen3.6 27B', + model_id: 'qwen3.6-27b', + status: 'running', + phase: 'installing-runtime', + detail: 'Unpacking runtime', + total_bytes: 100, + done_bytes: 100, + percent: 100, + error: null + } + ]) + + renderPane() + await screen.findByText('Unpacking runtime') + + // Stage detail yes; byte counter no — a 100% counter on an install + // phase would lie about the model leg still ahead. + expect(screen.queryByText(/of/)).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/settings/local-models-settings.tsx b/apps/desktop/src/app/settings/local-models-settings.tsx index f845bfeacd..89953a1320 100644 --- a/apps/desktop/src/app/settings/local-models-settings.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.tsx @@ -34,6 +34,7 @@ import { Loader2, Monitor, Package, + Pause, Search, StopFilled, Trash2, @@ -47,29 +48,17 @@ import { watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' import { notify, notifyError } from '@/store/notifications' -import type { LocalCatalogModel, LocalHardware, LocalModelsStatus } from '@/types/hermes' +import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' +import { + gbLabel, + isDownloadPhase, + LocalModelDownloadActions, + LocalModelDownloadProgress, + ProgressBar +} from './local-model-download-progress' import { ListRow, Pill, SettingsContent, SettingsSection, SettingsSkeleton } from './primitives' -function ProgressBar({ percent }: { percent: number | undefined }) { - return ( -
-
-
- ) -} - -function gbLabel(bytes: number | null | undefined): string { - if (!bytes) { - return '—' - } - - return `${(bytes / (1 << 30)).toFixed(1)} GB` -} - // Catalog display order: what runs well leads. Resident (all on GPU) // first, then spilled (works, slower), then doesn't-fit; catalog order // (recommended first) holds within each band. @@ -85,6 +74,12 @@ function fitRank(model: LocalCatalogModel): number { return 2 } +// Still on its way (or parked mid-way): rows/hero stay visible while +// paused — a paused download vanishing reads as progress loss. +function isActiveStatus(status: LocalRuntimeJob['status']): boolean { + return status === 'paused' || status === 'running' +} + export function LocalModelsSettings() { const { t } = useI18n() const copy = t.settings.localModels @@ -265,7 +260,7 @@ export function LocalModelsSettings() { const navigate = useNavigate() const seenQuickstarts = useRef(new Set()) - const runningQuickstart = jobs.find(j => j.kind === 'quickstart' && j.status === 'running') + const runningQuickstart = jobs.find(j => j.kind === 'quickstart' && isActiveStatus(j.status)) useEffect(() => { // Event detection, not value mirroring: the ref only remembers which @@ -306,7 +301,14 @@ export function LocalModelsSettings() { const needsSetup = !status.runtime_installed || status.models.length === 0 const heroModel = catalog.find(c => c.recommended && c.fits) ?? catalog.find(c => c.fits) ?? null - if (qJob || (needsSetup && !configure && heroModel)) { + // An active runtime install/update or model download needs the FULL pane + // (its row lives there with its controls) — the setup hero must not hide + // it on a remount. + const otherActiveJob = jobs.some( + j => (j.kind === 'runtime-install' || j.kind === 'model-download') && isActiveStatus(j.status) + ) + + if ((qJob || (needsSetup && !configure && heroModel)) && !otherActiveJob) { // Stage rail derived from the job phase: engine -> model -> finish. const phase = qJob?.phase ?? '' @@ -314,23 +316,17 @@ export function LocalModelsSettings() { const stages = [copy.quickstartStageEngine, copy.quickstartStageModel, copy.quickstartStageFinish] - // The model-download leg blanks job.detail on purpose (pane rows - // render their own byte counter) — compose one here instead of - // falling back to runtime copy that would misname the stage. - const liveDetail = - qJob && - (qJob.detail || - (qJob.total_bytes - ? copy.downloadProgress(gbLabel(qJob.done_bytes), gbLabel(qJob.total_bytes)) - : copy.installing)) - return (
{qJob ? ( - + qJob.status === 'paused' ? ( + + ) : ( + + ) ) : ( )} @@ -342,10 +338,28 @@ export function LocalModelsSettings() { {qJob ? ( <> -

{liveDetail}

+ {/* Byte counter only while bytes actually move: each stage + recomputes percent against ITS OWN download plan, and a + stage hand-off resets the counter by design — showing it + across a hand-off would read as progress loss. Paused + rows keep the frozen counter they parked with. */} +

+ {qJob.status === 'paused' + ? copy.downloadPausedLabel + : isDownloadPhase(qJob) && (qJob.detail || qJob.total_bytes) + ? qJob.detail || copy.downloadProgress(gbLabel(qJob.done_bytes), gbLabel(qJob.total_bytes)) + : qJob.detail || copy.installing} +

- + +
+ + {/* Pause / Resume — the backend's can_pause/can_resume gate + when controls exist (engine legs, server start report + false); the paused state always offers Resume. */} +
+
{/* Stage rail: engine -> model -> finish. */} @@ -456,11 +470,16 @@ export function LocalModelsSettings() { /> ) : rJob ? ( } + action={} + below={} description={rJob.detail || copy.installing} title={ - + {rJob.status === 'paused' ? ( + + ) : ( + + )} {copy.installing} } @@ -493,11 +512,16 @@ export function LocalModelsSettings() { {rJob && status.runtime_installed && ( } + action={} + below={} description={rJob.detail || copy.updating} title={ - + {rJob.status === 'paused' ? ( + + ) : ( + + )} {copy.updating} } @@ -554,7 +578,9 @@ export function LocalModelsSettings() {
{sortedCatalog.map(model => { const dJob = runningDownloadFor(jobs, model.id) - const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && j.status === 'running') + + const anyDownloadRunning = jobs.some(j => j.kind === 'model-download' && isActiveStatus(j.status)) + const activateTarget = model.downloaded_model_id ?? model.model_id const isActive = Boolean(activateTarget && status.active_model_id === activateTarget) const residency = activateTarget ? status.loaded_models[activateTarget] : undefined @@ -634,7 +660,9 @@ export function LocalModelsSettings() {
- ) : dJob ? undefined : ( + ) : dJob ? ( + + ) : (