From a186bb1798b3c46521a0f7a234f15d32ed6f9a0f Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 11 Jun 2026 17:57:59 -0400 Subject: [PATCH 01/51] feat: add starter picker and download progress components Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 2 + src-tauri/src/models/mod.rs | 8 + src-tauri/src/models/storage.rs | 13 + src/components/DownloadProgress.tsx | 344 ++++++++++++++ src/components/StarterPicker.tsx | 393 ++++++++++++++++ .../__tests__/DownloadProgress.test.tsx | 283 ++++++++++++ .../__tests__/StarterPicker.test.tsx | 262 +++++++++++ src/hooks/__tests__/useDownloadModel.test.tsx | 437 ++++++++++++++++++ src/hooks/useDownloadModel.ts | 292 ++++++++++++ src/types/starter.ts | 76 +++ 10 files changed, 2110 insertions(+) create mode 100644 src/components/DownloadProgress.tsx create mode 100644 src/components/StarterPicker.tsx create mode 100644 src/components/__tests__/DownloadProgress.test.tsx create mode 100644 src/components/__tests__/StarterPicker.test.tsx create mode 100644 src/hooks/__tests__/useDownloadModel.test.tsx create mode 100644 src/hooks/useDownloadModel.ts create mode 100644 src/types/starter.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce27792d..d2186c14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2068,6 +2068,8 @@ pub fn run() { #[cfg(not(coverage))] models::get_system_ram_bytes, #[cfg(not(coverage))] + models::get_models_dir_free_bytes, + #[cfg(not(coverage))] models::download_starter, #[cfg(not(coverage))] models::download_repo_model, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 43fe7530..452ab42b 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1443,6 +1443,14 @@ pub fn get_system_ram_bytes() -> u64 { system_ram_bytes() } +/// Free bytes on the volume holding the models directory, for the +/// pre-download disk-space line. `None` means unknown; the UI skips the line. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub fn get_models_dir_free_bytes(store: tauri::State<'_, storage::ModelStore>) -> Option { + store.free_bytes() +} + /// Starts downloading a curated starter (`tier` = "fast" | "balanced" | /// "smartest"). Progress streams over `on_event`; on success the model is /// recorded in the manifest and set as the builtin provider's model. diff --git a/src-tauri/src/models/storage.rs b/src-tauri/src/models/storage.rs index a1413a3d..12af11b3 100644 --- a/src-tauri/src/models/storage.rs +++ b/src-tauri/src/models/storage.rs @@ -115,6 +115,12 @@ impl ModelStore { let meta = std::fs::metadata(self.partial_path(sha256)).ok()?; Some(meta.len()) } + + /// Free bytes on the volume holding the store root, for the pre-download + /// disk-space line. `None` means unknown; callers skip the warning. + pub fn free_bytes(&self) -> Option { + free_disk_bytes(&self.root) + } } /// Free bytes available on the volume holding `path`. @@ -294,6 +300,13 @@ mod tests { assert!(free.is_some(), "expected Some on a real filesystem"); } + #[test] + fn store_free_bytes_delegates_to_root_volume() { + let (_dir, store) = make_store(); + let free = store.free_bytes(); + assert!(free.is_some(), "expected Some on a real filesystem"); + } + // ── StorageError display ───────────────────────────────────────────────── #[test] diff --git a/src/components/DownloadProgress.tsx b/src/components/DownloadProgress.tsx new file mode 100644 index 00000000..718d1492 --- /dev/null +++ b/src/components/DownloadProgress.tsx @@ -0,0 +1,344 @@ +/** + * Presentational download flow card: one render per useDownloadModel state. + * + * The component owns the per-state copy (including the exact failure + * strings) and emits plain callbacks; the state machine itself lives in + * useDownloadModel so onboarding and Settings share both halves. + */ + +import type React from 'react'; +import type { + DownloadProgressInfo, + DownloadUiState, +} from '../hooks/useDownloadModel'; + +/** Disk headroom (GB) below which the confirm card warns. Warn, never block. */ +const LOW_DISK_HEADROOM_GB = 2; + +export interface ConfirmInfo { + /** Total download size in decimal GB (weights + vision companion). */ + sizeGb: number; + /** Free disk space in decimal GB; null hides the disk line entirely. */ + freeDiskGb: number | null; + /** RAM-fit caution passed through from the picker; null hides it. */ + ramWarning: string | null; +} + +export interface DownloadProgressProps { + state: DownloadUiState; + progress: DownloadProgressInfo | null; + etaSeconds: number | null; + confirmInfo?: ConfirmInfo; + onConfirm: () => void; + onCancelConfirm: () => void; + onCancel: () => void; + onRetry: () => void; +} + +/** Seconds rendered as a compact countdown: "45s", "5m", "2h 1m". */ +function formatEta(etaSeconds: number): string { + if (etaSeconds < 60) return `${etaSeconds}s`; + if (etaSeconds < 3600) return `${Math.floor(etaSeconds / 60)}m`; + const hours = Math.floor(etaSeconds / 3600); + const minutes = Math.floor((etaSeconds % 3600) / 60); + return `${hours}h ${minutes}m`; +} + +/** Bytes rendered as decimal gigabytes with one decimal (e.g. "8.2"). */ +function gb(bytes: number): string { + return (bytes / 1e9).toFixed(1); +} + +/** Failure headline per kind. Exact copy; consumed verbatim by tests. */ +function failureHeadline(kind: string, message: string): string { + switch (kind) { + case 'offline': + return 'You appear to be offline.'; + case 'http': { + const status = /\b(\d{3})\b/.exec(message); + return status + ? `Hugging Face returned an error (status ${status[1]}).` + : 'Hugging Face returned an error.'; + } + case 'checksum': + return "Download didn't verify. Retrying re-downloads it."; + case 'disk_full': + return 'Not enough disk space. Free up space and retry.'; + case 'engine': + return "Thuki's engine could not start."; + default: + return message; + } +} + +export function DownloadProgress({ + state, + progress, + etaSeconds, + confirmInfo, + onConfirm, + onCancelConfirm, + onCancel, + onRetry, +}: DownloadProgressProps) { + switch (state.phase) { + case 'confirming': + return ( + + {confirmInfo ? ( + <> + {confirmInfo.sizeGb.toFixed(1)} GB download. + {confirmInfo.freeDiskGb !== null ? ( + + {confirmInfo.freeDiskGb.toFixed(1)} GB free on this disk. + + ) : null} + {confirmInfo.freeDiskGb !== null && + confirmInfo.freeDiskGb < + confirmInfo.sizeGb + LOW_DISK_HEADROOM_GB ? ( + + Low on disk space. The download may not fit. + + ) : null} + {confirmInfo.ramWarning !== null ? ( + {confirmInfo.ramWarning} + ) : null} + + ) : null} + + + + + + ); + case 'downloading': + case 'downloading_mmproj': + return ( + + + {state.phase === 'downloading_mmproj' + ? 'Downloading vision companion' + : 'Downloading model'} + + 0 + ? Math.floor((progress.bytes / progress.totalBytes) * 100) + : 0 + } + /> + {progress ? ( + + {gb(progress.bytes)} GB of {gb(progress.totalBytes)} GB + + ) : null} + {etaSeconds !== null ? ( + About {formatEta(etaSeconds)} left + ) : null} + + + + + ); + case 'verifying': + return ( + + Verifying download + + + ); + case 'installing': + return ( + + Installing + + + ); + case 'warming_up': + return ( + + Starting the engine + + + ); + case 'ready': + return ( + + + + + + + Ready + + + + ); + case 'failed': + return ( + + {failureHeadline(state.kind, state.message)} + {state.kind === 'http' ? {state.message} : null} + + + + + ); + default: + // idle and resume_pending have no progress UI; the picker owns them. + return null; + } +} + +function Card({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Headline({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function Detail({ + children, + warn = false, +}: { + children: React.ReactNode; + warn?: boolean; +}) { + return ( +

+ {children} +

+ ); +} + +interface ProgressBarProps { + percent?: number; + indeterminate?: boolean; +} + +function ProgressBar({ percent = 0, indeterminate = false }: ProgressBarProps) { + return ( +
+ {!indeterminate ? ( +
+ {percent}% +
+ ) : null} +
+
+
+
+ ); +} + +interface FlowButtonProps { + label: string; + onClick: () => void; + primary?: boolean; +} + +function FlowButton({ label, onClick, primary = false }: FlowButtonProps) { + return ( + + ); +} + +function ButtonRow({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} diff --git a/src/components/StarterPicker.tsx b/src/components/StarterPicker.tsx new file mode 100644 index 00000000..4a5bf546 --- /dev/null +++ b/src/components/StarterPicker.tsx @@ -0,0 +1,393 @@ +/** + * Three-tier starter model picker for the built-in engine. + * + * Presentational: the rows come in through `options` and every action is a + * callback, so onboarding and Settings can wire the same picker into their + * own flows. Data fetching lives in the colocated `useStarterOptions` hook + * (mirrors how ModelCheckStep keeps its probe beside its render tree). + */ + +import { useCallback, useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { RamFit, StarterOption, StarterTier } from '../types/starter'; + +const HF_BASE_URL = 'https://huggingface.co'; + +/** Tier pill labels, keyed by the registry's tier value. */ +const TIER_LABELS: Record = { + fast: 'Fast', + balanced: 'Balanced', + smartest: 'Smartest', +}; + +/** RAM-fit badge copy. Exact strings; consumed verbatim by tests. */ +const FIT_COPY: Record = { + fits: 'Runs comfortably on this Mac', + tight: "Will run, but close to this Mac's memory limit", + too_big: + "Larger than this Mac's memory can comfortably hold. Expect heavy slowdown.", +}; + +const FIT_COLORS: Record = { + fits: { color: '#22c55e', background: 'rgba(34,197,94,0.1)' }, + tight: { color: '#ff8d5c', background: 'rgba(255,141,92,0.1)' }, + too_big: { color: '#ef4444', background: 'rgba(239,68,68,0.1)' }, +}; + +/** Bytes rendered as decimal gigabytes with one decimal (e.g. "8.2"). */ +function gb(bytes: number): string { + return (bytes / 1e9).toFixed(1); +} + +/** Weights + vision companion, the full on-disk cost of one starter. */ +function totalBytes(option: StarterOption): number { + return option.starter.size_bytes + option.starter.mmproj_bytes; +} + +export interface UseStarterOptionsResult { + /** The picker rows; `null` while the first fetch is in flight. */ + options: StarterOption[] | null; + /** Re-fetch (e.g. after a cancel kept a resumable partial). */ + refresh: () => Promise; +} + +/** + * Loads the starter picker rows from the backend. A fetch failure degrades + * to an empty list so the picker renders nothing rather than crashing. + */ +export function useStarterOptions(): UseStarterOptionsResult { + const [options, setOptions] = useState(null); + + const refresh = useCallback(async () => { + try { + setOptions(await invoke('get_starter_options')); + } catch { + setOptions([]); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { options, refresh }; +} + +export interface StarterPickerProps { + options: StarterOption[]; + /** The highlighted tier. Consumers default this to 'balanced'. */ + selected: StarterTier; + onSelect: (tier: StarterTier) => void; + onDownload: (tier: StarterTier) => void; + onResume: (tier: StarterTier) => void; + onDiscard: (sha256: string) => void; + /** When true (and onUseOllama is wired), offers the Ollama escape hatch. */ + ollamaDetected?: boolean; + onUseOllama?: () => void; +} + +export function StarterPicker({ + options, + selected, + onSelect, + onDownload, + onResume, + onDiscard, + ollamaDetected, + onUseOllama, +}: StarterPickerProps) { + return ( +
+ {options.map((option) => ( + + ))} + {ollamaDetected && onUseOllama ? ( + + ) : null} +
+ ); +} + +interface StarterCardProps { + option: StarterOption; + selected: boolean; + onSelect: (tier: StarterTier) => void; + onDownload: (tier: StarterTier) => void; + onResume: (tier: StarterTier) => void; + onDiscard: (sha256: string) => void; +} + +function StarterCard({ + option, + selected, + onSelect, + onDownload, + onResume, + onDiscard, +}: StarterCardProps) { + const { starter, fit, installed, partial_bytes } = option; + const fitColors = FIT_COLORS[fit]; + + return ( +
onSelect(starter.tier)} + style={{ + padding: '12px 14px', + borderRadius: 14, + border: `1px solid ${ + selected ? 'rgba(255,141,92,0.4)' : 'rgba(255,255,255,0.06)' + }`, + background: selected + ? 'rgba(255,141,92,0.07)' + : 'rgba(255,255,255,0.03)', + boxShadow: selected + ? '0 0 20px rgba(255,141,92,0.08), inset 0 1px 0 rgba(255,141,92,0.1)' + : 'none', + cursor: 'pointer', + }} + > +
+
+ + {starter.display_name} + + + {TIER_LABELS[starter.tier]} + +
+ + {gb(totalBytes(option))} GB + +
+ +
+ {FIT_COPY[fit]} +
+ +
+ {starter.license_note} + + +
+ +
+ +
+
+ ); +} + +interface CardActionProps { + option: StarterOption; + installed: boolean; + partialBytes: number | null; + onDownload: (tier: StarterTier) => void; + onResume: (tier: StarterTier) => void; + onDiscard: (sha256: string) => void; +} + +/** + * The per-card affordance: an installed checkmark, a resume/discard pair + * when an interrupted partial exists, or the plain download button. + */ +function CardAction({ + option, + installed, + partialBytes, + onDownload, + onResume, + onDiscard, +}: CardActionProps) { + const { starter } = option; + + if (installed) { + return ( + + + + + Installed + + ); + } + + if (partialBytes !== null) { + return ( + + onResume(starter.tier)} + /> + onDiscard(starter.sha256)} + /> + + ); + } + + return ( + onDownload(starter.tier)} /> + ); +} + +interface ActionButtonProps { + label: string; + onClick: () => void; + muted?: boolean; +} + +function ActionButton({ label, onClick, muted = false }: ActionButtonProps) { + return ( + + ); +} diff --git a/src/components/__tests__/DownloadProgress.test.tsx b/src/components/__tests__/DownloadProgress.test.tsx new file mode 100644 index 00000000..4c2b88d8 --- /dev/null +++ b/src/components/__tests__/DownloadProgress.test.tsx @@ -0,0 +1,283 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { DownloadProgress } from '../DownloadProgress'; +import type { ConfirmInfo, DownloadProgressProps } from '../DownloadProgress'; +import type { + DownloadProgressInfo, + DownloadUiState, +} from '../../hooks/useDownloadModel'; + +function renderProgress( + state: DownloadUiState, + overrides?: Partial, +) { + const handlers = { + onConfirm: vi.fn(), + onCancelConfirm: vi.fn(), + onCancel: vi.fn(), + onRetry: vi.fn(), + }; + const utils = render( + , + ); + return { ...utils, ...handlers }; +} + +const confirmInfo = (overrides?: Partial): ConfirmInfo => ({ + sizeGb: 8.2, + freeDiskGb: 50, + ramWarning: null, + ...overrides, +}); + +describe('DownloadProgress', () => { + it('renders nothing for idle and resume_pending', () => { + const idle = renderProgress({ phase: 'idle' }); + expect(idle.container).toBeEmptyDOMElement(); + const pending = renderProgress({ phase: 'resume_pending' }); + expect(pending.container).toBeEmptyDOMElement(); + }); + + describe('confirming', () => { + it('shows the size, free disk space, and the action buttons', () => { + const { onConfirm, onCancelConfirm } = renderProgress( + { phase: 'confirming', tier: 'balanced' }, + { confirmInfo: confirmInfo() }, + ); + expect(screen.getByText('8.2 GB download.')).toBeInTheDocument(); + expect( + screen.getByText('50.0 GB free on this disk.'), + ).toBeInTheDocument(); + expect( + screen.queryByText('Low on disk space. The download may not fit.'), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancelConfirm).toHaveBeenCalledTimes(1); + }); + + it('warns when free disk is below size + 2 GB but keeps Download enabled', () => { + renderProgress( + { phase: 'confirming', tier: 'balanced' }, + { confirmInfo: confirmInfo({ freeDiskGb: 10.19 }) }, + ); + expect( + screen.getByText('Low on disk space. The download may not fit.'), + ).toBeInTheDocument(); + // Warn, never block: the Download button stays clickable. + expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled(); + }); + + it('hides the warning exactly at the size + 2 GB boundary', () => { + renderProgress( + { phase: 'confirming', tier: 'balanced' }, + { confirmInfo: confirmInfo({ freeDiskGb: 10.2 }) }, + ); + expect( + screen.queryByText('Low on disk space. The download may not fit.'), + ).not.toBeInTheDocument(); + }); + + it('skips the disk line when free space is unknown', () => { + renderProgress( + { phase: 'confirming', tier: 'balanced' }, + { confirmInfo: confirmInfo({ freeDiskGb: null }) }, + ); + expect(screen.getByText('8.2 GB download.')).toBeInTheDocument(); + expect(screen.queryByText(/free on this disk/)).not.toBeInTheDocument(); + }); + + it('passes the RAM warning through', () => { + renderProgress( + { phase: 'confirming', tier: 'smartest' }, + { + confirmInfo: confirmInfo({ + ramWarning: "Will run, but close to this Mac's memory limit", + }), + }, + ); + expect( + screen.getByText("Will run, but close to this Mac's memory limit"), + ).toBeInTheDocument(); + }); + + it('renders only the buttons when confirmInfo is absent', () => { + renderProgress({ phase: 'confirming', tier: 'fast' }); + expect(screen.queryByText(/GB download/)).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Download' }), + ).toBeInTheDocument(); + }); + }); + + describe('downloading', () => { + const progress: DownloadProgressInfo = { + file: 'weights.gguf', + bytes: 2_500_000_000, + totalBytes: 8_200_000_000, + }; + + it('shows percent, byte counts, ETA, and a working Cancel', () => { + const { onCancel } = renderProgress( + { phase: 'downloading' }, + { progress, etaSeconds: 300 }, + ); + expect(screen.getByText('Downloading model')).toBeInTheDocument(); + expect(screen.getByText('30%')).toBeInTheDocument(); + expect(screen.getByText('2.5 GB of 8.2 GB')).toBeInTheDocument(); + expect(screen.getByText('About 5m left')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('labels the mmproj phase as the vision companion', () => { + renderProgress( + { phase: 'downloading_mmproj' }, + { progress, etaSeconds: null }, + ); + expect( + screen.getByText('Downloading vision companion'), + ).toBeInTheDocument(); + expect(screen.queryByText(/left$/)).not.toBeInTheDocument(); + }); + + it('falls back to 0% before the first Started event lands', () => { + renderProgress({ phase: 'downloading' }); + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(screen.queryByText(/GB of/)).not.toBeInTheDocument(); + }); + + it('guards the percent math against a zero total', () => { + renderProgress( + { phase: 'downloading' }, + { progress: { file: 'w.gguf', bytes: 10, totalBytes: 0 } }, + ); + expect(screen.getByText('0%')).toBeInTheDocument(); + }); + + it('formats sub-minute and multi-hour ETAs', () => { + renderProgress({ phase: 'downloading' }, { progress, etaSeconds: 45 }); + expect(screen.getByText('About 45s left')).toBeInTheDocument(); + + renderProgress({ phase: 'downloading' }, { progress, etaSeconds: 7300 }); + expect(screen.getByText('About 2h 1m left')).toBeInTheDocument(); + }); + }); + + it('renders an indeterminate verifying state', () => { + const { container } = renderProgress({ phase: 'verifying' }); + expect(screen.getByText('Verifying download')).toBeInTheDocument(); + expect( + container.querySelector('[data-indeterminate="true"]'), + ).not.toBeNull(); + }); + + it('renders the installing state', () => { + renderProgress({ phase: 'installing' }); + expect(screen.getByText('Installing')).toBeInTheDocument(); + }); + + it('renders the warming up state', () => { + renderProgress({ phase: 'warming_up' }); + expect(screen.getByText('Starting the engine')).toBeInTheDocument(); + }); + + it('renders the ready checkmark', () => { + const { container } = renderProgress({ phase: 'ready' }); + expect(screen.getByText('Ready')).toBeInTheDocument(); + expect(container.querySelector('svg')).not.toBeNull(); + }); + + describe('failed', () => { + it('shows the offline copy with Retry', () => { + const { onRetry } = renderProgress({ + phase: 'failed', + kind: 'offline', + message: 'connection failed: dns error', + }); + expect(screen.getByText('You appear to be offline.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('extracts the status from an http failure and passes the message through', () => { + renderProgress({ + phase: 'failed', + kind: 'http', + message: 'server returned HTTP 403', + }); + expect( + screen.getByText('Hugging Face returned an error (status 403).'), + ).toBeInTheDocument(); + expect(screen.getByText('server returned HTTP 403')).toBeInTheDocument(); + }); + + it('falls back to a status-less http headline when no status is found', () => { + renderProgress({ + phase: 'failed', + kind: 'http', + message: 'server returned a strange response', + }); + expect( + screen.getByText('Hugging Face returned an error.'), + ).toBeInTheDocument(); + expect( + screen.getByText('server returned a strange response'), + ).toBeInTheDocument(); + }); + + it('shows the checksum copy', () => { + renderProgress({ + phase: 'failed', + kind: 'checksum', + message: 'checksum mismatch', + }); + expect( + screen.getByText("Download didn't verify. Retrying re-downloads it."), + ).toBeInTheDocument(); + }); + + it('shows the disk_full copy', () => { + renderProgress({ + phase: 'failed', + kind: 'disk_full', + message: 'write failed: no space left', + }); + expect( + screen.getByText('Not enough disk space. Free up space and retry.'), + ).toBeInTheDocument(); + }); + + it('shows the engine copy', () => { + renderProgress({ + phase: 'failed', + kind: 'engine', + message: 'spawn failed', + }); + expect( + screen.getByText("Thuki's engine could not start."), + ).toBeInTheDocument(); + }); + + it('shows the raw message for kind other', () => { + renderProgress({ + phase: 'failed', + kind: 'other', + message: 'invalid sha256 in download spec', + }); + expect( + screen.getByText('invalid sha256 in download spec'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/__tests__/StarterPicker.test.tsx b/src/components/__tests__/StarterPicker.test.tsx new file mode 100644 index 00000000..0f82ab2b --- /dev/null +++ b/src/components/__tests__/StarterPicker.test.tsx @@ -0,0 +1,262 @@ +import { + render, + screen, + fireEvent, + renderHook, + act, +} from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { StarterPicker, useStarterOptions } from '../StarterPicker'; +import { invoke } from '../../testUtils/mocks/tauri'; +import type { Starter, StarterOption, StarterTier } from '../../types/starter'; + +function makeStarter(tier: StarterTier, overrides?: Partial): Starter { + return { + tier, + display_name: `Model ${tier}`, + repo: `org/${tier}-repo`, + revision: 'a'.repeat(40), + file_name: `${tier}.gguf`, + sha256: 'b'.repeat(64), + size_bytes: 7_300_000_000, + quant: 'Q4_K_M', + vision: false, + thinking: false, + mmproj_file: null, + mmproj_sha256: null, + mmproj_bytes: 0, + est_runtime_gb: 10, + license_note: 'MIT', + ...overrides, + }; +} + +function makeOption( + tier: StarterTier, + overrides?: Partial, + starterOverrides?: Partial, +): StarterOption { + return { + starter: makeStarter(tier, starterOverrides), + fit: 'fits', + installed: false, + partial_bytes: null, + ...overrides, + }; +} + +const THREE_TIERS: StarterOption[] = [ + makeOption('fast', { fit: 'fits' }), + makeOption('balanced', { fit: 'tight' }), + makeOption('smartest', { fit: 'too_big' }), +]; + +function renderPicker( + options: StarterOption[], + props?: Partial[0]>, +) { + const handlers = { + onSelect: vi.fn(), + onDownload: vi.fn(), + onResume: vi.fn(), + onDiscard: vi.fn(), + }; + const utils = render( + , + ); + return { ...utils, ...handlers }; +} + +describe('StarterPicker', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('renders all three tiers with names and tier labels', () => { + renderPicker(THREE_TIERS); + expect(screen.getByText('Model fast')).toBeInTheDocument(); + expect(screen.getByText('Model balanced')).toBeInTheDocument(); + expect(screen.getByText('Model smartest')).toBeInTheDocument(); + expect(screen.getByText('Fast')).toBeInTheDocument(); + expect(screen.getByText('Balanced')).toBeInTheDocument(); + expect(screen.getByText('Smartest')).toBeInTheDocument(); + }); + + it('renders the combined weights + mmproj size in GB with one decimal', () => { + renderPicker([ + makeOption( + 'fast', + {}, + { size_bytes: 2_489_757_856, mmproj_bytes: 851_251_104 }, + ), + ]); + // (2_489_757_856 + 851_251_104) / 1e9 = 3.341 -> "3.3 GB" + expect(screen.getByText('3.3 GB')).toBeInTheDocument(); + }); + + it('renders the exact RAM-fit badge copy for every fit', () => { + renderPicker(THREE_TIERS); + expect( + screen.getByText('Runs comfortably on this Mac'), + ).toBeInTheDocument(); + expect( + screen.getByText("Will run, but close to this Mac's memory limit"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Larger than this Mac's memory can comfortably hold. Expect heavy slowdown.", + ), + ).toBeInTheDocument(); + }); + + it('opens the Hugging Face page via open_url from the license line', () => { + const { onSelect } = renderPicker([makeOption('fast')]); + expect(screen.getByText('MIT')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Open Model fast on Hugging Face' }), + ); + expect(invoke).toHaveBeenCalledWith('open_url', { + url: 'https://huggingface.co/org/fast-repo', + }); + // stopPropagation keeps the link from also selecting the card. + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('marks the selected tier card', () => { + const { container } = renderPicker(THREE_TIERS); + const cards = container.querySelectorAll('[data-starter-card]'); + expect(cards).toHaveLength(3); + expect( + container + .querySelector('[data-tier="balanced"]') + ?.getAttribute('data-selected'), + ).toBe('true'); + expect( + container + .querySelector('[data-tier="fast"]') + ?.getAttribute('data-selected'), + ).toBe('false'); + }); + + it('selects a tier when its card is clicked', () => { + const { container, onSelect } = renderPicker(THREE_TIERS); + fireEvent.click(container.querySelector('[data-tier="fast"]')!); + expect(onSelect).toHaveBeenCalledWith('fast'); + }); + + it('fires onDownload for a not-installed tier without a partial', () => { + const { onDownload, onSelect } = renderPicker([makeOption('smartest')]); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(onDownload).toHaveBeenCalledWith('smartest'); + // stopPropagation: the action button must not also select the card. + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('shows the installed checkmark instead of a download button', () => { + renderPicker([makeOption('fast', { installed: true })]); + expect(screen.getByText('Installed')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Download' }), + ).not.toBeInTheDocument(); + }); + + it('offers resume and discard when a partial exists', () => { + const { onResume, onDiscard } = renderPicker([ + makeOption( + 'balanced', + { partial_bytes: 1_200_000_000 }, + { size_bytes: 7_300_000_000, mmproj_bytes: 854_200_224 }, + ), + ]); + // 1.2 of (7_300_000_000 + 854_200_224)/1e9 = 8.154 -> 8.2 GB + const resume = screen.getByRole('button', { + name: 'Resume download (1.2 of 8.2 GB)', + }); + fireEvent.click(resume); + expect(onResume).toHaveBeenCalledWith('balanced'); + + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(onDiscard).toHaveBeenCalledWith('b'.repeat(64)); + }); + + it('shows the Ollama escape hatch only when detected and wired', () => { + const onUseOllama = vi.fn(); + const { rerender } = renderPicker(THREE_TIERS, { + ollamaDetected: true, + onUseOllama, + }); + fireEvent.click( + screen.getByRole('button', { name: 'Use my existing Ollama instead' }), + ); + expect(onUseOllama).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect( + screen.queryByText('Use my existing Ollama instead'), + ).not.toBeInTheDocument(); + + rerender( + , + ); + expect( + screen.queryByText('Use my existing Ollama instead'), + ).not.toBeInTheDocument(); + }); +}); + +describe('useStarterOptions', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('starts null and loads the options on mount', async () => { + invoke.mockResolvedValueOnce(THREE_TIERS); + const { result } = renderHook(() => useStarterOptions()); + expect(result.current.options).toBeNull(); + await act(async () => {}); + expect(result.current.options).toEqual(THREE_TIERS); + expect(invoke).toHaveBeenCalledWith('get_starter_options'); + }); + + it('degrades to an empty list when the fetch rejects', async () => { + invoke.mockRejectedValueOnce('backend down'); + const { result } = renderHook(() => useStarterOptions()); + await act(async () => {}); + expect(result.current.options).toEqual([]); + }); + + it('re-fetches on refresh', async () => { + invoke.mockResolvedValueOnce([]); + const { result } = renderHook(() => useStarterOptions()); + await act(async () => {}); + expect(result.current.options).toEqual([]); + + invoke.mockResolvedValueOnce(THREE_TIERS); + await act(() => result.current.refresh()); + expect(result.current.options).toEqual(THREE_TIERS); + }); +}); diff --git a/src/hooks/__tests__/useDownloadModel.test.tsx b/src/hooks/__tests__/useDownloadModel.test.tsx new file mode 100644 index 00000000..fc5aad26 --- /dev/null +++ b/src/hooks/__tests__/useDownloadModel.test.tsx @@ -0,0 +1,437 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { computeEtaSeconds, useDownloadModel } from '../useDownloadModel'; +import { + invoke, + getLastChannel, + resetChannelCapture, + enableChannelCapture, + emitTauriEvent, + clearEventHandlers, + type Channel, +} from '../../testUtils/mocks/tauri'; +import type { DownloadEvent, DownloadFailKind } from '../../types/starter'; + +/** The captured download channel, typed for simulateMessage calls. */ +function channel(): Channel { + const captured = getLastChannel(); + expect(captured).not.toBeNull(); + return captured as Channel; +} + +describe('useDownloadModel', () => { + beforeEach(() => { + invoke.mockReset(); + enableChannelCapture(); + }); + + afterEach(() => { + resetChannelCapture(); + clearEventHandlers(); + vi.restoreAllMocks(); + }); + + it('starts idle with no progress and no ETA', () => { + const { result } = renderHook(() => useDownloadModel()); + expect(result.current.state).toEqual({ phase: 'idle' }); + expect(result.current.progress).toBeNull(); + expect(result.current.etaSeconds).toBeNull(); + }); + + it('walks the full happy path: confirm, download, mmproj, verify, ready', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(0); + const { result } = renderHook(() => useDownloadModel()); + + act(() => result.current.beginConfirm('balanced')); + expect(result.current.state).toEqual({ + phase: 'confirming', + tier: 'balanced', + }); + + act(() => result.current.cancelConfirm()); + expect(result.current.state).toEqual({ phase: 'idle' }); + + act(() => result.current.beginConfirm('balanced')); + await act(() => result.current.start('balanced')); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenCalledWith('download_starter', { + tier: 'balanced', + onEvent: expect.anything(), + }); + + // Weights file begins; resumed_from seeds the progress bytes. + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'weights.gguf', total_bytes: 100, resumed_from: 0 }, + }), + ); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(result.current.progress).toEqual({ + file: 'weights.gguf', + bytes: 0, + totalBytes: 100, + }); + expect(result.current.etaSeconds).toBeNull(); + + // First Progress sample: no ETA yet (needs two samples). + act(() => + channel().simulateMessage({ + type: 'Progress', + data: { file: 'weights.gguf', bytes: 10, total_bytes: 100 }, + }), + ); + expect(result.current.progress?.bytes).toBe(10); + expect(result.current.etaSeconds).toBeNull(); + + // Second sample 5s later: 40 bytes over 5s = 8 B/s; 50 remaining = ~6s. + now.mockReturnValue(5000); + act(() => + channel().simulateMessage({ + type: 'Progress', + data: { file: 'weights.gguf', bytes: 50, total_bytes: 100 }, + }), + ); + expect(result.current.etaSeconds).toBe(6); + + act(() => + channel().simulateMessage({ + type: 'Verifying', + data: { file: 'weights.gguf' }, + }), + ); + expect(result.current.state).toEqual({ phase: 'verifying' }); + + // FileDone is interim: the state holds until the next Started. + act(() => + channel().simulateMessage({ + type: 'FileDone', + data: { file: 'weights.gguf' }, + }), + ); + expect(result.current.state).toEqual({ phase: 'verifying' }); + + // Second Started is the vision companion; the ETA window resets. + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'mmproj.gguf', total_bytes: 50, resumed_from: 0 }, + }), + ); + expect(result.current.state).toEqual({ phase: 'downloading_mmproj' }); + expect(result.current.etaSeconds).toBeNull(); + + act(() => + channel().simulateMessage({ + type: 'Verifying', + data: { file: 'mmproj.gguf' }, + }), + ); + act(() => + channel().simulateMessage({ + type: 'FileDone', + data: { file: 'mmproj.gguf' }, + }), + ); + + // Without awaitEngine, AllDone lands directly on ready. + act(() => channel().simulateMessage({ type: 'AllDone' })); + expect(result.current.state).toEqual({ phase: 'ready' }); + }); + + it('drops ETA samples older than the 10s window', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(0); + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('fast')); + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'w.gguf', total_bytes: 1000, resumed_from: 0 }, + }), + ); + + // Sample at t=0 (bytes 0) falls out of the window by t=15s; the rate + // then comes from t=5s..15s: 100 bytes over 10s = 10 B/s. + const sendProgress = (bytes: number) => + act(() => + channel().simulateMessage({ + type: 'Progress', + data: { file: 'w.gguf', bytes, total_bytes: 1000 }, + }), + ); + sendProgress(0); + now.mockReturnValue(5000); + sendProgress(100); + now.mockReturnValue(15000); + sendProgress(200); + + // Remaining 800 bytes at 10 B/s = 80s. With the stale t=0 sample the + // rate would be 200/15s and the ETA 60s instead. + expect(result.current.etaSeconds).toBe(80); + }); + + it('treats a post-AllDone Failed as terminal failure', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('smartest')); + act(() => channel().simulateMessage({ type: 'AllDone' })); + expect(result.current.state).toEqual({ phase: 'ready' }); + + act(() => + channel().simulateMessage({ + type: 'Failed', + data: { kind: 'other', message: 'manifest write failed' }, + }), + ); + expect(result.current.state).toEqual({ + phase: 'failed', + kind: 'other', + message: 'manifest write failed', + }); + }); + + it.each([ + 'offline', + 'http', + 'checksum', + 'disk_full', + 'other', + ])('maps a Failed event of kind %s onto the failed state', async (kind) => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('fast')); + act(() => + channel().simulateMessage({ + type: 'Failed', + data: { kind, message: `boom: ${kind}` }, + }), + ); + expect(result.current.state).toEqual({ + phase: 'failed', + kind, + message: `boom: ${kind}`, + }); + }); + + it('returns to idle on Cancelled and clears progress', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('fast')); + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'w.gguf', total_bytes: 100, resumed_from: 40 }, + }), + ); + expect(result.current.progress?.bytes).toBe(40); + + await act(() => result.current.cancel()); + expect(invoke).toHaveBeenCalledWith('cancel_model_download'); + // State waits for the backend's Cancelled event. + expect(result.current.state).toEqual({ phase: 'downloading' }); + + act(() => channel().simulateMessage({ type: 'Cancelled' })); + expect(result.current.state).toEqual({ phase: 'idle' }); + expect(result.current.progress).toBeNull(); + expect(result.current.etaSeconds).toBeNull(); + }); + + it('fails with kind other when the start invoke rejects', async () => { + invoke.mockRejectedValueOnce('a download is already in progress'); + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('fast')); + expect(result.current.state).toEqual({ + phase: 'failed', + kind: 'other', + message: 'a download is already in progress', + }); + }); + + it('retries the last tier after a failure', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('smartest')); + act(() => + channel().simulateMessage({ + type: 'Failed', + data: { kind: 'checksum', message: 'checksum mismatch' }, + }), + ); + + await act(() => result.current.retry()); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenLastCalledWith('download_starter', { + tier: 'smartest', + onEvent: expect.anything(), + }); + }); + + it('ignores retry before any start recorded a tier', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.retry()); + expect(result.current.state).toEqual({ phase: 'idle' }); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('resumes through the same start call', async () => { + const { result } = renderHook(() => useDownloadModel()); + act(() => result.current.enterResumePending()); + expect(result.current.state).toEqual({ phase: 'resume_pending' }); + + await act(() => result.current.resume('balanced')); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenCalledWith('download_starter', { + tier: 'balanced', + onEvent: expect.anything(), + }); + }); + + it('discards a partial and returns to idle', async () => { + const { result } = renderHook(() => useDownloadModel()); + act(() => result.current.enterResumePending()); + + await act(() => result.current.discard('a'.repeat(64))); + expect(invoke).toHaveBeenCalledWith('discard_partial_download', { + sha256: 'a'.repeat(64), + }); + expect(result.current.state).toEqual({ phase: 'idle' }); + }); + + it('surfaces a discard failure as kind other', async () => { + invoke.mockRejectedValueOnce('invalid sha256'); + const { result } = renderHook(() => useDownloadModel()); + act(() => result.current.enterResumePending()); + + await act(() => result.current.discard('nope')); + expect(result.current.state).toEqual({ + phase: 'failed', + kind: 'other', + message: 'invalid sha256', + }); + }); + + describe('awaitEngine: true', () => { + const engineStatus = ( + state: 'stopped' | 'starting' | 'loaded' | 'stopping' | 'failed', + error: string | null = null, + ) => ({ state, model_path: '/m.gguf', port: null, error }); + + it('parks on installing at AllDone, then follows engine:status to ready', async () => { + const { result } = renderHook(() => + useDownloadModel({ awaitEngine: true }), + ); + await act(() => result.current.start('fast')); + act(() => channel().simulateMessage({ type: 'AllDone' })); + expect(result.current.state).toEqual({ phase: 'installing' }); + + act(() => emitTauriEvent('engine:status', engineStatus('starting'))); + expect(result.current.state).toEqual({ phase: 'warming_up' }); + + act(() => emitTauriEvent('engine:status', engineStatus('loaded'))); + expect(result.current.state).toEqual({ phase: 'ready' }); + }); + + it('jumps installing -> ready when loaded arrives without starting', async () => { + const { result } = renderHook(() => + useDownloadModel({ awaitEngine: true }), + ); + await act(() => result.current.start('fast')); + act(() => channel().simulateMessage({ type: 'AllDone' })); + + act(() => emitTauriEvent('engine:status', engineStatus('loaded'))); + expect(result.current.state).toEqual({ phase: 'ready' }); + }); + + it('fails with kind engine when the engine reports failed', async () => { + const { result } = renderHook(() => + useDownloadModel({ awaitEngine: true }), + ); + await act(() => result.current.start('fast')); + act(() => channel().simulateMessage({ type: 'AllDone' })); + + act(() => + emitTauriEvent( + 'engine:status', + engineStatus('failed', 'spawn failed: ENOENT'), + ), + ); + expect(result.current.state).toEqual({ + phase: 'failed', + kind: 'engine', + message: 'spawn failed: ENOENT', + }); + }); + + it('falls back to a default message when the failed status has no error', async () => { + const { result } = renderHook(() => + useDownloadModel({ awaitEngine: true }), + ); + await act(() => result.current.start('fast')); + act(() => channel().simulateMessage({ type: 'AllDone' })); + act(() => emitTauriEvent('engine:status', engineStatus('starting'))); + + act(() => emitTauriEvent('engine:status', engineStatus('failed'))); + expect(result.current.state).toEqual({ + phase: 'failed', + kind: 'engine', + message: 'the engine could not start', + }); + }); + + it('ignores engine:status outside installing and warming_up', () => { + const { result } = renderHook(() => + useDownloadModel({ awaitEngine: true }), + ); + act(() => emitTauriEvent('engine:status', engineStatus('starting'))); + expect(result.current.state).toEqual({ phase: 'idle' }); + }); + + it('ignores intermediate stopping statuses while installing', async () => { + const { result } = renderHook(() => + useDownloadModel({ awaitEngine: true }), + ); + await act(() => result.current.start('fast')); + act(() => channel().simulateMessage({ type: 'AllDone' })); + + act(() => emitTauriEvent('engine:status', engineStatus('stopping'))); + expect(result.current.state).toEqual({ phase: 'installing' }); + }); + + it('detaches the engine:status listener on unmount', async () => { + const { unmount } = renderHook(() => + useDownloadModel({ awaitEngine: true }), + ); + unmount(); + // Flush the unlisten promise chain, then verify the handler is gone. + await act(async () => {}); + emitTauriEvent('engine:status', engineStatus('starting')); + }); + }); +}); + +describe('computeEtaSeconds', () => { + it('returns null with fewer than two samples', () => { + expect(computeEtaSeconds([], 0, 100)).toBeNull(); + expect(computeEtaSeconds([{ t: 0, bytes: 0 }], 0, 100)).toBeNull(); + }); + + it('returns null when no time elapsed between window edges', () => { + const samples = [ + { t: 1000, bytes: 0 }, + { t: 1000, bytes: 50 }, + ]; + expect(computeEtaSeconds(samples, 50, 100)).toBeNull(); + }); + + it('returns null when bytes did not advance', () => { + const samples = [ + { t: 0, bytes: 50 }, + { t: 5000, bytes: 50 }, + ]; + expect(computeEtaSeconds(samples, 50, 100)).toBeNull(); + }); + + it('clamps the estimate at zero when bytes overshoot the total', () => { + const samples = [ + { t: 0, bytes: 0 }, + { t: 1000, bytes: 150 }, + ]; + expect(computeEtaSeconds(samples, 150, 100)).toBe(0); + }); +}); diff --git a/src/hooks/useDownloadModel.ts b/src/hooks/useDownloadModel.ts new file mode 100644 index 00000000..4f093e53 --- /dev/null +++ b/src/hooks/useDownloadModel.ts @@ -0,0 +1,292 @@ +/** + * Download-state machine for starter model downloads. + * + * Drives the shared download UI (StarterPicker + DownloadProgress) through + * one discriminated-union state, fed by the `download_starter` Tauri channel + * and, optionally, the `engine:status` Tauri event. + * + * Engine handoff: by default `AllDone` transitions straight to `ready`, + * because after a Settings-context download nobody starts the engine until + * the first chat, so waiting on `engine:status` would hang forever. A + * consumer that does prime the engine right after the download (onboarding) + * passes `awaitEngine: true`; then `AllDone` parks in `installing` and the + * `engine:status` listener advances `installing -> warming_up -> ready` + * (or `failed` with kind `engine`). + * + * A `Failed` event can arrive AFTER `AllDone` (finalize failure: the + * manifest write failed), so `Failed` is terminal from any state. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Channel, invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import type { + DownloadEvent, + DownloadFailKind, + EngineStatus, + StarterTier, +} from '../types/starter'; + +/** Failure kinds the UI can show: the backend's plus the engine handoff's. */ +export type DownloadUiFailKind = DownloadFailKind | 'engine'; + +/** The download UI state machine's discriminated union. */ +export type DownloadUiState = + | { phase: 'idle' } + | { phase: 'confirming'; tier: StarterTier } + | { phase: 'downloading' } + | { phase: 'downloading_mmproj' } + | { phase: 'verifying' } + | { phase: 'installing' } + | { phase: 'warming_up' } + | { phase: 'ready' } + | { phase: 'resume_pending' } + | { phase: 'failed'; kind: DownloadUiFailKind; message: string }; + +/** Last reported byte counts for the file currently downloading. */ +export interface DownloadProgressInfo { + file: string; + bytes: number; + totalBytes: number; +} + +/** One ETA sample: a Progress event's byte count and arrival time. */ +interface EtaSample { + t: number; + bytes: number; +} + +/** Rolling-rate window: only Progress samples this recent feed the ETA. */ +const ETA_WINDOW_MS = 10_000; + +/** + * Remaining seconds from the rolling sample window, or `null` while the + * rate is not yet measurable (fewer than two samples, zero elapsed time, + * or no forward progress between the window's edges). + */ +export function computeEtaSeconds( + samples: EtaSample[], + bytes: number, + totalBytes: number, +): number | null { + if (samples.length < 2) return null; + const first = samples[0]; + const last = samples[samples.length - 1]; + const elapsedSeconds = (last.t - first.t) / 1000; + const deltaBytes = last.bytes - first.bytes; + if (elapsedSeconds <= 0 || deltaBytes <= 0) return null; + const bytesPerSecond = deltaBytes / elapsedSeconds; + return Math.max(0, Math.round((totalBytes - bytes) / bytesPerSecond)); +} + +export interface UseDownloadModel { + state: DownloadUiState; + progress: DownloadProgressInfo | null; + etaSeconds: number | null; + /** idle -> confirming. No backend call; shows the confirm card. */ + beginConfirm: (tier: StarterTier) => void; + /** confirming -> idle. */ + cancelConfirm: () => void; + /** confirming -> downloading; invokes `download_starter` with a channel. */ + start: (tier: StarterTier) => Promise; + /** + * Invokes `cancel_model_download`. The state flips back to idle when the + * backend's Cancelled event lands; the partial is KEPT, so the caller + * refreshes options to surface resume_pending. + */ + cancel: () => Promise; + /** + * failed -> downloading. A checksum failure already deleted the partial + * on the backend, so retrying is just starting the same tier again. + */ + retry: () => Promise; + /** resume_pending -> downloading; the backend resumes via Range. */ + resume: (tier: StarterTier) => Promise; + /** resume_pending -> idle; invokes `discard_partial_download`. */ + discard: (sha256: string) => Promise; + /** Caller sets this when starter options show partial_bytes. */ + enterResumePending: () => void; +} + +export interface UseDownloadModelOptions { + /** + * When true, `AllDone` parks in `installing` and `engine:status` drives + * the warming_up/ready/failed handoff. Leave false (the default) unless + * the consumer starts the engine immediately after the download. + */ + awaitEngine?: boolean; +} + +export function useDownloadModel( + options?: UseDownloadModelOptions, +): UseDownloadModel { + const awaitEngine = options?.awaitEngine === true; + + const [state, setState] = useState({ phase: 'idle' }); + const [progress, setProgress] = useState(null); + const [etaSeconds, setEtaSeconds] = useState(null); + + const samplesRef = useRef([]); + const startedCountRef = useRef(0); + const lastTierRef = useRef(null); + + const handleEvent = useCallback( + (event: DownloadEvent) => { + switch (event.type) { + case 'Started': { + startedCountRef.current += 1; + samplesRef.current = []; + setEtaSeconds(null); + setProgress({ + file: event.data.file, + bytes: event.data.resumed_from, + totalBytes: event.data.total_bytes, + }); + // The second Started is always the mmproj companion: specs are + // ordered weights first, mmproj second. + setState( + startedCountRef.current >= 2 + ? { phase: 'downloading_mmproj' } + : { phase: 'downloading' }, + ); + break; + } + case 'Progress': { + const now = Date.now(); + const samples = samplesRef.current; + samples.push({ t: now, bytes: event.data.bytes }); + while (samples.length > 0 && now - samples[0].t > ETA_WINDOW_MS) { + samples.shift(); + } + setProgress({ + file: event.data.file, + bytes: event.data.bytes, + totalBytes: event.data.total_bytes, + }); + setEtaSeconds( + computeEtaSeconds( + samples, + event.data.bytes, + event.data.total_bytes, + ), + ); + break; + } + case 'Verifying': + setState({ phase: 'verifying' }); + break; + case 'FileDone': + // Interim: the next Started (mmproj) or AllDone moves the state. + break; + case 'AllDone': + setState(awaitEngine ? { phase: 'installing' } : { phase: 'ready' }); + break; + case 'Cancelled': + setProgress(null); + setEtaSeconds(null); + setState({ phase: 'idle' }); + break; + case 'Failed': + // Terminal from ANY state, including after AllDone (finalize + // failure: the manifest write failed). + setState({ + phase: 'failed', + kind: event.data.kind, + message: event.data.message, + }); + break; + } + }, + [awaitEngine], + ); + + useEffect(() => { + if (!awaitEngine) return; + const unlistenPromise = listen('engine:status', (event) => { + const status = event.payload; + setState((prev) => { + if (prev.phase !== 'installing' && prev.phase !== 'warming_up') { + return prev; + } + if (status.state === 'starting') return { phase: 'warming_up' }; + if (status.state === 'loaded') return { phase: 'ready' }; + if (status.state === 'failed') { + return { + phase: 'failed', + kind: 'engine', + message: status.error ?? 'the engine could not start', + }; + } + return prev; + }); + }); + return () => { + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, [awaitEngine]); + + const beginConfirm = useCallback((tier: StarterTier) => { + setState({ phase: 'confirming', tier }); + }, []); + + const cancelConfirm = useCallback(() => { + setState({ phase: 'idle' }); + }, []); + + const start = useCallback( + async (tier: StarterTier) => { + lastTierRef.current = tier; + startedCountRef.current = 0; + samplesRef.current = []; + setProgress(null); + setEtaSeconds(null); + setState({ phase: 'downloading' }); + const channel = new Channel(); + channel.onmessage = handleEvent; + try { + await invoke('download_starter', { tier, onEvent: channel }); + } catch (err) { + setState({ phase: 'failed', kind: 'other', message: String(err) }); + } + }, + [handleEvent], + ); + + const cancel = useCallback(async () => { + await invoke('cancel_model_download'); + }, []); + + const retry = useCallback(async () => { + const tier = lastTierRef.current; + if (tier === null) return; + await start(tier); + }, [start]); + + const discard = useCallback(async (sha256: string) => { + try { + await invoke('discard_partial_download', { sha256 }); + } catch (err) { + setState({ phase: 'failed', kind: 'other', message: String(err) }); + return; + } + setState({ phase: 'idle' }); + }, []); + + const enterResumePending = useCallback(() => { + setState({ phase: 'resume_pending' }); + }, []); + + return { + state, + progress, + etaSeconds, + beginConfirm, + cancelConfirm, + start, + cancel, + retry, + resume: start, + discard, + enterResumePending, + }; +} diff --git a/src/types/starter.ts b/src/types/starter.ts new file mode 100644 index 00000000..011506de --- /dev/null +++ b/src/types/starter.ts @@ -0,0 +1,76 @@ +/** + * IPC shapes for the built-in engine's starter model downloads. + * + * Mirrors the serde output of the Rust side: + * - `src-tauri/src/models/registry.rs` (Starter, Tier, RamFit; snake_case) + * - `src-tauri/src/models/mod.rs` (StarterOption) + * - `src-tauri/src/models/download.rs` (DownloadEvent; adjacently tagged + * with `type`/`data`, variant names verbatim, kind values snake_case) + * - `src-tauri/src/engine/runner.rs` (EngineStatus, emitted on the + * `engine:status` Tauri event) + */ + +/** Coarse speed/quality dial; the picker's three rows. */ +export type StarterTier = 'fast' | 'balanced' | 'smartest'; + +/** RAM-fit hint computed by the backend from `hw.memsize`. */ +export type RamFit = 'fits' | 'tight' | 'too_big'; + +/** One curated starter model from the compile-time registry. */ +export interface Starter { + tier: StarterTier; + display_name: string; + repo: string; + revision: string; + file_name: string; + sha256: string; + size_bytes: number; + quant: string; + vision: boolean; + thinking: boolean; + mmproj_file: string | null; + mmproj_sha256: string | null; + mmproj_bytes: number; + est_runtime_gb: number; + license_note: string; +} + +/** One starter picker row: registry entry plus machine-specific facts. */ +export interface StarterOption { + starter: Starter; + fit: RamFit; + installed: boolean; + partial_bytes: number | null; +} + +/** Failure category carried by a `Failed` download event. */ +export type DownloadFailKind = + | 'offline' + | 'http' + | 'checksum' + | 'disk_full' + | 'other'; + +/** Progress events streamed over the `download_starter` channel. */ +export type DownloadEvent = + | { + type: 'Started'; + data: { file: string; total_bytes: number; resumed_from: number }; + } + | { + type: 'Progress'; + data: { file: string; bytes: number; total_bytes: number }; + } + | { type: 'Verifying'; data: { file: string } } + | { type: 'FileDone'; data: { file: string } } + | { type: 'AllDone' } + | { type: 'Cancelled' } + | { type: 'Failed'; data: { kind: DownloadFailKind; message: string } }; + +/** Engine lifecycle snapshot published on the `engine:status` event. */ +export interface EngineStatus { + state: 'stopped' | 'starting' | 'loaded' | 'stopping' | 'failed'; + model_path: string; + port: number | null; + error: string | null; +} From 7639be3ab7aa5e209aa4197774408fb58a64d48e Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 11 Jun 2026 18:29:51 -0400 Subject: [PATCH 02/51] feat: rework onboarding model step around the built-in engine Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 3 + src-tauri/src/models/mod.rs | 202 +++++- src-tauri/src/settings_commands.rs | 73 +++ src-tauri/src/settings_commands/tests.rs | 89 ++- src/components/StarterPicker.tsx | 5 +- src/contexts/ConfigContext.tsx | 14 + src/contexts/__tests__/ConfigContext.test.tsx | 52 ++ src/view/onboarding/ModelCheckStep.tsx | 397 +++++++++++- .../__tests__/ModelCheckStep.test.tsx | 580 +++++++++++++++++- 9 files changed, 1394 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2186c14..79495743 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2050,6 +2050,7 @@ pub fn run() { settings_commands::get_config, settings_commands::set_config_field, settings_commands::set_ollama_url, + settings_commands::set_active_provider, settings_commands::reset_config, settings_commands::reload_config_from_disk, settings_commands::get_corrupt_marker, @@ -2062,6 +2063,8 @@ pub fn run() { #[cfg(not(coverage))] models::check_model_setup, #[cfg(not(coverage))] + models::detect_ollama, + #[cfg(not(coverage))] models::get_model_capabilities, #[cfg(not(coverage))] models::get_starter_options, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 452ab42b..2539519e 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -31,7 +31,7 @@ use crate::config::defaults::{ DEFAULT_OLLAMA_SHOW_REQUEST_TIMEOUT_SECS, DEFAULT_OLLAMA_TAGS_REQUEST_TIMEOUT_SECS, HF_API_TIMEOUT_SECS, HF_BASE_URL, MAX_HF_API_BODY_BYTES, MAX_MODEL_SLUG_LEN, MAX_OLLAMA_SHOW_BODY_BYTES, MAX_OLLAMA_TAGS_BODY_BYTES, PROVIDER_ID_BUILTIN, - PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OPENAI, + PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, PROVIDER_KIND_OPENAI, }; use crate::config::AppConfig; @@ -420,6 +420,10 @@ pub enum ModelSetupState { /// `/api/tags` responded successfully but the installed list is empty. /// The UI must guide the user to `ollama pull `. NoModelsInstalled, + /// The active provider has no usable model yet (built-in engine with no + /// downloaded starter, or an `openai` provider with no model configured). + /// The UI must offer the starter download picker. + NeedsDownload, /// Ollama is running with at least one installed model. `active_slug` /// is the slug we resolved (persisted preference if still installed, /// else first installed) and `installed` is the live list for the @@ -469,9 +473,78 @@ pub fn derive_model_setup_state( } } -/// Probes Ollama for setup readiness and returns the typed +/// Pure setup gate for the built-in engine: Ready when the provider has a +/// model selected AND that model is recorded in the installed manifest; +/// NeedsDownload otherwise (no model chosen yet, or the manifest row was +/// removed out from under a stale provider pointer). +/// +/// `installed` carries every manifest id so the Ready payload mirrors the +/// Ollama arm's shape (active slug + full inventory). +pub fn derive_builtin_setup_state( + provider_model: Option<&str>, + manifest_ids: &[String], +) -> ModelSetupState { + match provider_model { + Some(model) if manifest_ids.iter().any(|id| id == model) => ModelSetupState::Ready { + active_slug: model.to_string(), + installed: manifest_ids.to_vec(), + }, + _ => ModelSetupState::NeedsDownload, + } +} + +/// Defensive setup gate for an `openai`-kind active provider. Onboarding never +/// sets one active, but if a hand-edited config does, a configured model is +/// treated as Ready (there is no probe surface to verify against) and an +/// unconfigured one falls back to the download picker. +pub fn derive_openai_setup_state(provider_model: Option<&str>) -> ModelSetupState { + match provider_model { + Some(model) => ModelSetupState::Ready { + active_slug: model.to_string(), + installed: vec![model.to_string()], + }, + None => ModelSetupState::NeedsDownload, + } +} + +/// Base URL of the configured Ollama provider, regardless of which provider +/// is active. Empty when no Ollama-kind provider exists (the loader always +/// seeds one, so the fallback is defensive). +pub fn ollama_provider_base_url(config: &AppConfig) -> String { + config + .inference + .providers + .iter() + .find(|p| p.kind == PROVIDER_KIND_OLLAMA) + .map(|p| p.base_url.clone()) + .unwrap_or_default() +} + +/// True when a local Ollama daemon answered `/api/tags` on the configured +/// Ollama provider's base URL, regardless of how many models it reports. +/// Backs onboarding's "Use my existing Ollama instead" escape hatch while +/// the built-in provider is active (so `get_model_picker_state`, which +/// probes the ACTIVE provider and mutates the active-model mirror, cannot +/// be reused here). +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub async fn detect_ollama( + client: tauri::State<'_, reqwest::Client>, + config: tauri::State<'_, parking_lot::RwLock>, +) -> Result { + let base_url = ollama_provider_base_url(&config.read()); + Ok(fetch_installed_model_names(&client, &base_url) + .await + .is_ok()) +} + +/// Probes the active provider for setup readiness and returns the typed /// [`ModelSetupState`] for the frontend onboarding gate. /// +/// Routing is by provider kind: `builtin` consults the installed-model +/// manifest, `openai` trusts its configured model, and Ollama probes +/// `/api/tags` exactly as before. +/// /// Idempotent: safe to call on every overlay open. The Ready arm also /// commits two side effects, both intentionally bounded: /// @@ -494,11 +567,29 @@ pub async fn check_model_setup( client: tauri::State<'_, reqwest::Client>, active_model: tauri::State<'_, ActiveModelState>, config: tauri::State<'_, parking_lot::RwLock>, + db: tauri::State<'_, crate::history::Database>, ) -> Result { let (ollama_url, active_id, persisted) = read_provider_model_context(&config); - let installed_result = fetch_installed_model_names(&client, &ollama_url).await; + let kind = config.read().inference.active_provider_kind().to_string(); - let state = derive_model_setup_state(installed_result, persisted.as_deref()); + let state = match kind.as_str() { + PROVIDER_KIND_BUILTIN => { + let ids: Vec = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + manifest::list(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .map(|m| m.id) + .collect() + }; + derive_builtin_setup_state(persisted.as_deref(), &ids) + } + PROVIDER_KIND_OPENAI => derive_openai_setup_state(persisted.as_deref()), + _ => { + let installed_result = fetch_installed_model_names(&client, &ollama_url).await; + derive_model_setup_state(installed_result, persisted.as_deref()) + } + }; if let ModelSetupState::Ready { ref active_slug, @@ -2254,6 +2345,109 @@ mod tests { "installed": ["gemma4:e2b"], }) ); + + let needs_download = serde_json::to_value(ModelSetupState::NeedsDownload).unwrap(); + assert_eq!( + needs_download, + serde_json::json!({"state": "needs_download"}) + ); + } + + // ── derive_builtin_setup_state / derive_openai_setup_state ─────────────── + + #[test] + fn builtin_ready_when_model_and_manifest() { + // Round-trip through a real in-memory manifest so the ids carry + // exactly what a finished download recorded. + let conn = crate::database::open_in_memory().unwrap(); + manifest::insert(&conn, &manifest_row("org/repo:w.gguf", false, false)).unwrap(); + manifest::insert(&conn, &manifest_row("org/repo:x.gguf", false, false)).unwrap(); + let ids: Vec = manifest::list(&conn) + .unwrap() + .into_iter() + .map(|m| m.id) + .collect(); + + let state = derive_builtin_setup_state(Some("org/repo:w.gguf"), &ids); + assert_eq!( + state, + ModelSetupState::Ready { + active_slug: "org/repo:w.gguf".to_string(), + installed: ids, + } + ); + } + + #[test] + fn builtin_needs_download_when_no_model() { + // Fresh install: nothing selected, nothing downloaded. + let conn = crate::database::open_in_memory().unwrap(); + let ids: Vec = manifest::list(&conn) + .unwrap() + .into_iter() + .map(|m| m.id) + .collect(); + assert_eq!( + derive_builtin_setup_state(None, &ids), + ModelSetupState::NeedsDownload + ); + } + + #[test] + fn builtin_needs_download_when_manifest_row_missing() { + // The provider points at a model whose manifest row is gone (e.g. + // deleted between launches). The gate must re-engage, not trust the + // stale pointer. + let conn = crate::database::open_in_memory().unwrap(); + manifest::insert(&conn, &manifest_row("org/repo:other.gguf", false, false)).unwrap(); + let ids: Vec = manifest::list(&conn) + .unwrap() + .into_iter() + .map(|m| m.id) + .collect(); + assert_eq!( + derive_builtin_setup_state(Some("org/repo:gone.gguf"), &ids), + ModelSetupState::NeedsDownload + ); + } + + #[test] + fn openai_ready_when_model_configured() { + assert_eq!( + derive_openai_setup_state(Some("gpt-4o")), + ModelSetupState::Ready { + active_slug: "gpt-4o".to_string(), + installed: vec!["gpt-4o".to_string()], + } + ); + } + + #[test] + fn openai_needs_download_when_no_model_configured() { + assert_eq!( + derive_openai_setup_state(None), + ModelSetupState::NeedsDownload + ); + } + + // ── ollama_provider_base_url (detect_ollama's config read) ────────────── + + #[test] + fn ollama_provider_base_url_reads_ollama_kind_entry() { + // The default config seeds builtin first, Ollama second; the lookup + // must key on kind, not position or active_provider. + let cfg = AppConfig::default(); + assert_eq!( + ollama_provider_base_url(&cfg), + crate::config::defaults::DEFAULT_OLLAMA_URL + ); + } + + #[test] + fn ollama_provider_base_url_empty_when_no_ollama_provider() { + let mut cfg = AppConfig::default(); + cfg.inference.providers.retain(|p| p.kind != "ollama"); + assert_eq!(ollama_provider_base_url(&cfg), ""); } // ── capabilities_from_strings ──────────────────────────────────────────── diff --git a/src-tauri/src/settings_commands.rs b/src-tauri/src/settings_commands.rs index 20e1cdf8..e474fdbe 100644 --- a/src-tauri/src/settings_commands.rs +++ b/src-tauri/src/settings_commands.rs @@ -269,6 +269,79 @@ pub(crate) fn write_field_to_disk( config::load_from_path(path) } +/// Switches the active inference provider and returns the resolved `AppConfig`. +/// +/// Validates that `provider_id` names an entry in the on-disk +/// `[[inference.providers]]` list, persists `[inference] active_provider`, +/// refreshes the managed config, and re-mirrors the in-memory +/// [`crate::models::ActiveModelState`] onto the new active provider's model +/// (Some when non-empty, None otherwise) so chat routes correctly without a +/// restart. Mirrors `set_ollama_url`'s lock + persist + broadcast contract. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn set_active_provider( + provider_id: String, + app: AppHandle, + state: State<'_, RwLock>, + active_model: State<'_, crate::models::ActiveModelState>, +) -> Result { + let path = config_path(&app)?; + let resolved = { + let mut guard = state.write(); + let resolved = write_active_provider_to_disk(&path, &provider_id)?; + *guard = resolved.clone(); + resolved + }; + if let Some(mirror) = crate::models::should_refresh_active_model(&provider_id, &resolved) { + if let Ok(mut guard) = active_model.0.lock() { + *guard = mirror; + } + } + emit_config_updated(&app); + Ok(resolved) +} + +/// Persists `[inference] active_provider = provider_id` after validating that +/// the id names an entry in the on-disk `[[inference.providers]]` list, +/// preserving the rest of the file via `toml_edit`, then reloads + resolves. +/// Sibling of [`write_provider_field_to_disk`]; pulled out of the Tauri +/// wrapper so the validation, atomic write, and post-write reload are +/// exercised without an `AppHandle`. +pub(crate) fn write_active_provider_to_disk( + path: &Path, + provider_id: &str, +) -> Result { + let mut doc = read_document(path)?; + let providers = doc + .get("inference") + .and_then(|i| i.get("providers")) + .and_then(|p| p.as_array_of_tables()); + let Some(providers) = providers else { + return Err(ConfigError::UnknownSection { + section: "inference.providers".to_string(), + }); + }; + let known = providers + .iter() + .any(|t| t.get("id").and_then(|v| v.as_str()) == Some(provider_id)); + if !known { + return Err(ConfigError::UnknownField { + section: "inference.providers".to_string(), + key: provider_id.to_string(), + }); + } + if let Some(table) = doc.get_mut("inference").and_then(Item::as_table_mut) { + table.insert("active_provider", toml_value(provider_id)); + } + config::atomic_write_bytes(path, doc.to_string().as_bytes()).map_err(|source| { + ConfigError::IoError { + path: path.to_path_buf(), + source, + } + })?; + config::load_from_path(path) +} + /// Patches a single field (`model` or `base_url`) on the /// `[[inference.providers]]` entry whose `id` matches `provider_id`, preserving /// the rest of the file via `toml_edit`, then reloads + resolves. Backs the diff --git a/src-tauri/src/settings_commands/tests.rs b/src-tauri/src/settings_commands/tests.rs index aaf3b10c..1069548e 100644 --- a/src-tauri/src/settings_commands/tests.rs +++ b/src-tauri/src/settings_commands/tests.rs @@ -14,7 +14,8 @@ use toml_edit::DocumentMut; use super::{ coerce_json_to_toml, idle_unload_minutes_changed, is_allowed_field, is_allowed_section, json_type_name, json_value_to_toml_item, patch_document, read_document, reset_section_on_disk, - trace_enabled_changed, write_field_to_disk, write_provider_field_to_disk, + trace_enabled_changed, write_active_provider_to_disk, write_field_to_disk, + write_provider_field_to_disk, }; use crate::config::defaults::{ALLOWED_FIELDS, ALLOWED_SECTIONS}; use crate::config::{AppConfig, ConfigError}; @@ -893,6 +894,92 @@ fn write_provider_field_propagates_read_error_for_missing_file() { matches!(err, ConfigError::IoError { .. }); } +// ─── write_active_provider_to_disk ────────────────────────────────────────── + +#[test] +fn set_active_provider_updates_active_and_mirror() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + + // Give the builtin provider a model first, so the mirror decision below + // exercises the Some(non-empty) arm the command relies on. + write_provider_field_to_disk(&path, "builtin", "model", "org/repo:w.gguf").unwrap(); + + let resolved = write_active_provider_to_disk(&path, "builtin").unwrap(); + assert_eq!(resolved.inference.active_provider, "builtin"); + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains("active_provider = \"builtin\"")); + + // The command refreshes the ActiveModelState mirror through this exact + // decision helper: the new active provider's model, empty mapped to None. + assert_eq!( + crate::models::should_refresh_active_model("builtin", &resolved), + Some(Some("org/repo:w.gguf".to_string())) + ); + + // Switching back to a provider with no model clears the mirror. + let resolved = write_active_provider_to_disk(&path, "ollama").unwrap(); + assert_eq!(resolved.inference.active_provider, "ollama"); + assert_eq!( + crate::models::should_refresh_active_model("ollama", &resolved), + Some(None) + ); +} + +#[test] +fn set_active_provider_rejects_unknown_id() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + let err = write_active_provider_to_disk(&path, "ghost").unwrap_err(); + match err { + ConfigError::UnknownField { section, key } => { + assert_eq!(section, "inference.providers"); + assert_eq!(key, "ghost"); + } + other => panic!("expected UnknownField, got {other:?}"), + } + // The file is untouched: the active provider pointer keeps its old value. + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains("active_provider = \"ollama\"")); +} + +#[test] +fn set_active_provider_errors_when_no_providers_array() { + // SAMPLE_CONFIG is the pre-providers shape (no [[inference.providers]]). + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + let err = write_active_provider_to_disk(&path, "ollama").unwrap_err(); + match err { + ConfigError::UnknownSection { section } => assert_eq!(section, "inference.providers"), + other => panic!("expected UnknownSection, got {other:?}"), + } +} + +#[cfg(unix)] +#[test] +fn set_active_provider_propagates_io_error_when_parent_dir_is_readonly() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + + let mut perms = std::fs::metadata(&dir).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&dir, perms.clone()).unwrap(); + + let err = write_active_provider_to_disk(&path, "builtin").unwrap_err(); + + // Restore writability so the OS can clean up the tempdir later. + let mut restore = perms; + restore.set_mode(0o700); + std::fs::set_permissions(&dir, restore).unwrap(); + + matches!(err, ConfigError::IoError { .. }); +} + #[cfg(unix)] #[test] fn write_provider_field_propagates_io_error_when_parent_dir_is_readonly() { diff --git a/src/components/StarterPicker.tsx b/src/components/StarterPicker.tsx index 4a5bf546..5fe3cf1c 100644 --- a/src/components/StarterPicker.tsx +++ b/src/components/StarterPicker.tsx @@ -20,8 +20,9 @@ const TIER_LABELS: Record = { smartest: 'Smartest', }; -/** RAM-fit badge copy. Exact strings; consumed verbatim by tests. */ -const FIT_COPY: Record = { +/** RAM-fit badge copy. Exact strings; consumed verbatim by tests. Exported so + * onboarding can pass the same caution into the confirm card's RAM warning. */ +export const FIT_COPY: Record = { fits: 'Runs comfortably on this Mac', tight: "Will run, but close to this Mac's memory limit", too_big: diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index f8178a43..c09551ed 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -76,6 +76,8 @@ export interface AppConfig { inference: { /** Id of the active provider (e.g. `'ollama'`). */ activeProvider: string; + /** Kind of the active provider (`'builtin' | 'ollama' | 'openai'`). */ + activeProviderKind: string; /** Base URL of the Ollama provider, derived from the providers list. */ ollamaUrl: string; }; @@ -114,10 +116,21 @@ function ollamaBaseUrl(raw: RawAppConfig): string { ); } +/** Derives the active provider's kind from the providers list. Falls back to + * `'ollama'` when the pointer does not resolve (the loader repairs dangling + * pointers, so this only fires in test contexts with a partial list). */ +function activeProviderKind(raw: RawAppConfig): string { + return ( + raw.inference.providers.find((p) => p.id === raw.inference.active_provider) + ?.kind ?? 'ollama' + ); +} + function transform(raw: RawAppConfig): AppConfig { return { inference: { activeProvider: raw.inference.active_provider, + activeProviderKind: activeProviderKind(raw), ollamaUrl: ollamaBaseUrl(raw), }, prompt: { @@ -264,6 +277,7 @@ export function ConfigProviderForTest({ export const DEFAULT_CONFIG: AppConfig = { inference: { activeProvider: 'ollama', + activeProviderKind: 'ollama', ollamaUrl: 'http://127.0.0.1:11434', }, prompt: { system: '' }, diff --git a/src/contexts/__tests__/ConfigContext.test.tsx b/src/contexts/__tests__/ConfigContext.test.tsx index 55a015a0..5cac2401 100644 --- a/src/contexts/__tests__/ConfigContext.test.tsx +++ b/src/contexts/__tests__/ConfigContext.test.tsx @@ -19,6 +19,9 @@ function Probe() { return ( <>
{config.inference.ollamaUrl}
+
+ {config.inference.activeProviderKind} +
{config.window.overlayWidth}
{config.window.maxChatHeight}
{config.window.textBasePx}
@@ -65,6 +68,7 @@ describe('ConfigContext', () => { ...DEFAULT_CONFIG, inference: { activeProvider: 'ollama', + activeProviderKind: 'ollama', ollamaUrl: 'http://example.test:11434', }, }; @@ -124,6 +128,9 @@ describe('ConfigContext', () => { expect(screen.getByTestId('ollama-url').textContent).toBe( 'http://127.0.0.1:11434', ); + expect(screen.getByTestId('active-provider-kind').textContent).toBe( + 'ollama', + ); expect(screen.getByTestId('overlay-width').textContent).toBe('800'); expect(screen.getByTestId('max-chat-height').textContent).toBe('700'); expect(screen.getByTestId('text-base-px').textContent).toBe('17'); @@ -174,6 +181,51 @@ describe('ConfigContext', () => { await act(async () => {}); expect(screen.getByTestId('ollama-url').textContent).toBe(''); + expect(screen.getByTestId('active-provider-kind').textContent).toBe( + 'builtin', + ); + }); + + it('falls back to the ollama kind when the active provider pointer dangles', async () => { + invoke.mockResolvedValueOnce({ + inference: { + active_provider: 'ghost', + providers: [ + { + id: 'ollama', + kind: 'ollama', + base_url: 'http://127.0.0.1:11434', + }, + ], + }, + prompt: { system: '' }, + window: { + overlay_width: 600, + max_chat_height: 648, + max_images: 3, + text_base_px: 15, + text_line_height: 1.5, + text_letter_spacing_px: 0, + text_font_weight: 500, + }, + quote: { + max_display_lines: 4, + max_display_chars: 300, + max_context_length: 4096, + }, + behavior: { auto_replace: false, auto_close: false }, + }); + + render( + + + , + ); + await act(async () => {}); + + expect(screen.getByTestId('active-provider-kind').textContent).toBe( + 'ollama', + ); }); it('falls back to DEFAULT_CONFIG when invoke returns nullish', async () => { diff --git a/src/view/onboarding/ModelCheckStep.tsx b/src/view/onboarding/ModelCheckStep.tsx index 5af751a7..7282b57b 100644 --- a/src/view/onboarding/ModelCheckStep.tsx +++ b/src/view/onboarding/ModelCheckStep.tsx @@ -1,19 +1,19 @@ /** - * Onboarding step that gates the chat overlay on a working local Ollama - * setup with at least one installed model. + * Onboarding step that gates the chat overlay on a usable model for the + * active inference provider. * - * Layout: - * - Vertical timeline rail with numbered nodes connected by a thin line. - * - Step 1 active shows a single title row, then a two-tab install hero - * (Install Ollama / Already Installed?) above a single code box that - * swaps its command per tab. A short sub-line below the box invites - * the user to paste the command or visit the Ollama docs. - * - Step 2 active hosts a compact list of starter models, all rendered - * equal — no badge, no hierarchy. The user picks whichever fits. + * Dispatches on the active provider's kind: + * - `builtin` (the default): a RAM-aware three-tier starter picker with + * one-tap download (StarterPicker + DownloadProgress + useDownloadModel, + * the same kit Settings uses). When a local Ollama daemon is detected, + * a "Use my existing Ollama instead" escape hatch switches the active + * provider and falls into the legacy Ollama flow below. + * - anything else: the original Ollama state machine + * (ollama_unreachable / no_models_installed / ready), kept verbatim. * - * Probes Ollama via the `check_model_setup` Tauri command on mount and on - * every Re-check click. Background polling is intentionally absent so - * idle CPU and IPC stay at zero between explicit user actions. + * The Ollama machine probes via the `check_model_setup` Tauri command on + * mount and on every Re-check click. Background polling is intentionally + * absent so idle CPU and IPC stay at zero between explicit user actions. */ import { AnimatePresence, motion } from 'framer-motion'; @@ -22,6 +22,20 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import thukiLogo from '../../../src-tauri/icons/128x128.png'; import { useConfig } from '../../contexts/ConfigContext'; +import { + FIT_COPY, + StarterPicker, + useStarterOptions, +} from '../../components/StarterPicker'; +import { + DownloadProgress, + type ConfirmInfo, +} from '../../components/DownloadProgress'; +import { + useDownloadModel, + type DownloadUiState, +} from '../../hooks/useDownloadModel'; +import type { StarterOption, StarterTier } from '../../types/starter'; import { Badge } from './_shared'; const OLLAMA_DOCS_URL = 'https://ollama.com/download'; @@ -44,6 +58,7 @@ function formatListenAddr(url: string): string { type ModelSetupState = | { state: 'ollama_unreachable' } | { state: 'no_models_installed' } + | { state: 'needs_download' } | { state: 'ready'; active_slug: string; installed: string[] }; interface InstallTab { @@ -115,7 +130,363 @@ async function copyToClipboard(text: string): Promise { } } +/** + * Dispatches between the built-in starter flow and the legacy Ollama state + * machine based on the active provider's kind. `ollamaOverride` flips when + * the user takes the "Use my existing Ollama instead" escape hatch, so the + * legacy machine renders immediately without waiting for the config-updated + * broadcast to round-trip. + */ export function ModelCheckStep() { + const config = useConfig(); + const [ollamaOverride, setOllamaOverride] = useState(false); + + if (config.inference.activeProviderKind !== 'builtin' || ollamaOverride) { + return ; + } + return setOllamaOverride(true)} />; +} + +// ─── Built-in engine flow ──────────────────────────────────────────────────── + +/** Download phases during which the escape hatch must stay reachable. */ +function isDownloadingPhase(phase: string): boolean { + return phase === 'downloading' || phase === 'downloading_mmproj'; +} + +/** + * Confirm-card facts for the tier being confirmed: total download size, the + * disk's free space, and the picker's RAM caution for non-comfortable fits. + * `undefined` outside the confirming phase (or, defensively, when the tier + * has no matching option row) hides the info block entirely. + */ +export function buildConfirmInfo( + state: DownloadUiState, + options: StarterOption[], + freeDiskBytes: number | null, +): ConfirmInfo | undefined { + if (state.phase !== 'confirming') return undefined; + const option = options.find((o) => o.starter.tier === state.tier); + if (!option) return undefined; + return { + sizeGb: (option.starter.size_bytes + option.starter.mmproj_bytes) / 1e9, + freeDiskGb: freeDiskBytes === null ? null : freeDiskBytes / 1e9, + ramWarning: option.fit === 'fits' ? null : FIT_COPY[option.fit], + }; +} + +/** + * Starter picker + one-tap download for the built-in engine. + * + * Mount probes: + * - `check_model_setup`: a returning user whose starter is already + * installed advances straight past this step. + * - `detect_ollama`: gates the "Use my existing Ollama instead" hatch. + * - `get_models_dir_free_bytes`: feeds the confirm card's disk line. + * + * Download lifecycle is owned by `useDownloadModel` (awaitEngine off: the + * engine starts lazily on first chat, so `AllDone` is terminal here). On + * `ready` the options refresh (so the row shows Installed) and the backend + * advances onboarding to the intro step. + */ +function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { + const { options, refresh } = useStarterOptions(); + const { + state, + progress, + etaSeconds, + beginConfirm, + cancelConfirm, + start, + cancel, + retry, + resume, + discard, + enterResumePending, + } = useDownloadModel(); + const [selected, setSelected] = useState('balanced'); + const [ollamaDetected, setOllamaDetected] = useState(false); + const [freeDiskBytes, setFreeDiskBytes] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const setup = await invoke('check_model_setup'); + if (cancelled) return; + if (setup.state === 'ready') { + await invoke('advance_past_model_check'); + } + } catch { + // Probe failure is not fatal: stay on the picker so the user can + // still download a starter. + } + })(); + void invoke('detect_ollama') + .then((detected) => { + if (!cancelled) setOllamaDetected(detected); + }) + .catch(() => { + // Detection failure just hides the escape hatch. + }); + void invoke('get_models_dir_free_bytes') + .then((bytes) => { + if (!cancelled) setFreeDiskBytes(bytes ?? null); + }) + .catch(() => { + // Unknown free space hides the disk line; never blocks the download. + }); + return () => { + cancelled = true; + }; + }, []); + + // An interrupted earlier download leaves a resumable partial: surface the + // per-card Resume/Discard pair instead of the plain Download button. + useEffect(() => { + if ( + state.phase === 'idle' && + options !== null && + options.some((o) => o.partial_bytes !== null) + ) { + enterResumePending(); + } + }, [state.phase, options, enterResumePending]); + + // Download finished: refresh the rows so Installed shows, then let the + // backend advance onboarding (it re-emits the stage event). + useEffect(() => { + if (state.phase !== 'ready') return; + void (async () => { + await refresh(); + await invoke('advance_past_model_check'); + })(); + }, [state.phase, refresh]); + + const handleUseOllama = useCallback(async () => { + if (isDownloadingPhase(state.phase)) { + await cancel(); + } + try { + await invoke('set_active_provider', { providerId: 'ollama' }); + } catch { + // Switching failed (e.g. config write error): stay on the picker. + return; + } + onUseOllama(); + }, [state.phase, cancel, onUseOllama]); + + const pickerVisible = + state.phase === 'idle' || + state.phase === 'confirming' || + state.phase === 'resume_pending'; + const hatchBesideProgress = + ollamaDetected && + (isDownloadingPhase(state.phase) || state.phase === 'failed'); + + return ( + + {options === null ? null : ( + <> + {pickerVisible ? ( +
+ { + setSelected(tier); + beginConfirm(tier); + }} + onResume={(tier) => { + setSelected(tier); + void resume(tier); + }} + onDiscard={(sha256) => { + void discard(sha256).then(refresh); + }} + ollamaDetected={ollamaDetected} + onUseOllama={() => void handleUseOllama()} + /> + +
+ ) : null} + void start(selected)} + onCancelConfirm={cancelConfirm} + onCancel={() => void cancel()} + onRetry={() => void retry()} + /> + {hatchBesideProgress ? ( + + ) : null} + + )} +
+ ); +} + +/** + * Phase 3 stub: the full model browser (paste-a-repo, search) ships later. + * Disabled on purpose; the styling marks it as a preview, not a dead button. + */ +function MoreOptionsStub() { + return ( + + ); +} + +/** + * Outer card for the built-in flow. Mirrors the legacy machine's shell + * (logo, title, privacy footer) so onboarding stays visually coherent; the + * legacy markup itself is left untouched inside `OllamaModelCheck`. + */ +function BuiltinShell({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ +
+ Thuki +
+ +

+ Set up your local AI +

+

+ Pick a starter brain for Thuki. Downloads once, then runs fully + offline. +

+ + {children} + +

+ Private by default · All inference runs on your machine +

+ +
+ ); +} + +// ─── Legacy Ollama flow (kept verbatim) ────────────────────────────────────── + +function OllamaModelCheck() { const [setupState, setSetupState] = useState(null); const [isRechecking, setIsRechecking] = useState(false); const mountedRef = useRef(true); diff --git a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx index c18f4242..f2e0f2a0 100644 --- a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx +++ b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx @@ -4,18 +4,27 @@ import { fireEvent, act, waitFor, + within, cleanup, } from '@testing-library/react'; import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; -import { ModelCheckStep } from '../ModelCheckStep'; +import { ModelCheckStep, buildConfirmInfo } from '../ModelCheckStep'; import { ConfigProviderForTest, DEFAULT_CONFIG, + type AppConfig, } from '../../../contexts/ConfigContext'; import { invoke, enableChannelCaptureWithResponses, + getLastChannel, + resetChannelCapture, } from '../../../testUtils/mocks/tauri'; +import type { + Starter, + StarterOption, + StarterTier, +} from '../../../types/starter'; const READY_RESPONSE = { state: 'ready', @@ -693,3 +702,572 @@ describe('ModelCheckStep', () => { expect(screen.queryByText('Copied')).not.toBeInTheDocument(); }); }); + +// ─── Built-in engine flow ──────────────────────────────────────────────────── + +function makeStarter(tier: StarterTier, overrides?: Partial): Starter { + return { + tier, + display_name: `Model ${tier}`, + repo: `org/${tier}-repo`, + revision: 'a'.repeat(40), + file_name: `${tier}.gguf`, + sha256: 'b'.repeat(64), + size_bytes: 7_300_000_000, + quant: 'Q4_K_M', + vision: false, + thinking: false, + mmproj_file: null, + mmproj_sha256: null, + mmproj_bytes: 0, + est_runtime_gb: 10, + license_note: 'MIT', + ...overrides, + }; +} + +function makeOption( + tier: StarterTier, + overrides?: Partial, +): StarterOption { + return { + starter: makeStarter(tier), + fit: 'fits', + installed: false, + partial_bytes: null, + ...overrides, + }; +} + +const BUILTIN_OPTIONS: StarterOption[] = [ + makeOption('fast', { fit: 'fits' }), + makeOption('balanced', { fit: 'tight' }), + makeOption('smartest', { fit: 'too_big' }), +]; + +const BUILTIN_CONFIG: AppConfig = { + ...DEFAULT_CONFIG, + inference: { + ...DEFAULT_CONFIG.inference, + activeProvider: 'builtin', + activeProviderKind: 'builtin', + }, +}; + +function builtinResponses(overrides: Record = {}) { + enableChannelCaptureWithResponses({ + check_model_setup: { state: 'needs_download' }, + get_starter_options: BUILTIN_OPTIONS, + detect_ollama: true, + get_models_dir_free_bytes: 50_000_000_000, + ...overrides, + }); +} + +function renderBuiltin() { + return render( + + + , + ); +} + +/** Clicks the per-card Download button, then confirms in the confirm card. */ +async function startDownload(container: HTMLElement, tier: StarterTier) { + const card = container.querySelector(`[data-tier="${tier}"]`)!; + await act(async () => { + fireEvent.click( + within(card as HTMLElement).getByRole('button', { name: 'Download' }), + ); + }); + const progressCard = container.querySelector('[data-download-progress]')!; + await act(async () => { + fireEvent.click( + within(progressCard as HTMLElement).getByRole('button', { + name: 'Download', + }), + ); + }); +} + +describe('ModelCheckStep (builtin flow)', () => { + beforeEach(() => { + invoke.mockClear(); + resetChannelCapture(); + }); + + it('renders the picker with Balanced preselected, the more-options stub, and the escape hatch', async () => { + builtinResponses(); + + const { container } = renderBuiltin(); + await act(async () => {}); + + expect( + container + .querySelector('[data-tier="balanced"]') + ?.getAttribute('data-selected'), + ).toBe('true'); + expect( + container + .querySelector('[data-tier="fast"]') + ?.getAttribute('data-selected'), + ).toBe('false'); + const stub = screen.getByRole('button', { + name: 'More options · Full model browser coming soon', + }); + expect(stub).toBeDisabled(); + expect( + screen.getByText('Use my existing Ollama instead'), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Private by default · All inference runs on your machine', + ), + ).toBeInTheDocument(); + }); + + it('hides the escape hatch when Ollama is not detected', async () => { + builtinResponses({ detect_ollama: false }); + + renderBuiltin(); + await act(async () => {}); + + expect( + screen.queryByText('Use my existing Ollama instead'), + ).not.toBeInTheDocument(); + }); + + it('selecting another card moves the highlight', async () => { + builtinResponses(); + + const { container } = renderBuiltin(); + await act(async () => {}); + + await act(async () => { + fireEvent.click(container.querySelector('[data-tier="fast"]')!); + }); + expect( + container + .querySelector('[data-tier="fast"]') + ?.getAttribute('data-selected'), + ).toBe('true'); + }); + + it('one-tap download shows confirm facts, walks to ready, refreshes options, and advances', async () => { + builtinResponses({ advance_past_model_check: undefined }); + + const { container } = renderBuiltin(); + await act(async () => {}); + + const balancedCard = container.querySelector('[data-tier="balanced"]')!; + await act(async () => { + fireEvent.click( + within(balancedCard as HTMLElement).getByRole('button', { + name: 'Download', + }), + ); + }); + + // Confirm card: size, free-disk line, RAM warning (balanced is 'tight': + // once on the picker badge, once in the confirm card). + expect(screen.getByText('7.3 GB download.')).toBeInTheDocument(); + expect(screen.getByText('50.0 GB free on this disk.')).toBeInTheDocument(); + expect( + screen.getAllByText("Will run, but close to this Mac's memory limit"), + ).toHaveLength(2); + + const progressCard = container.querySelector('[data-download-progress]')!; + await act(async () => { + fireEvent.click( + within(progressCard as HTMLElement).getByRole('button', { + name: 'Download', + }), + ); + }); + expect(invoke).toHaveBeenCalledWith( + 'download_starter', + expect.objectContaining({ tier: 'balanced' }), + ); + + const channel = getLastChannel()!; + await act(async () => { + channel.simulateMessage({ + type: 'Started', + data: { file: 'balanced.gguf', total_bytes: 100, resumed_from: 0 }, + }); + }); + expect(screen.getByText('Downloading model')).toBeInTheDocument(); + // The picker is hidden while the download runs. + expect(container.querySelector('[data-starter-card]')).toBeNull(); + + await act(async () => { + channel.simulateMessage({ type: 'AllDone' }); + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('advance_past_model_check'); + }); + expect( + invoke.mock.calls.filter((c) => c[0] === 'get_starter_options'), + ).toHaveLength(2); + }); + + it('advances immediately when check_model_setup already reports ready', async () => { + builtinResponses({ + check_model_setup: READY_RESPONSE, + advance_past_model_check: undefined, + }); + + renderBuiltin(); + await act(async () => {}); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('advance_past_model_check'); + }); + }); + + it('stays on the picker when the setup probe rejects', async () => { + builtinResponses(); + const base = invoke.getMockImplementation()!; + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'check_model_setup') throw new Error('ipc broken'); + return base(cmd, args); + }); + + renderBuiltin(); + await act(async () => {}); + + expect(screen.getByText('Model balanced')).toBeInTheDocument(); + expect(invoke).not.toHaveBeenCalledWith('advance_past_model_check'); + }); + + it('hides the disk line and the hatch when the auxiliary probes reject', async () => { + builtinResponses(); + const base = invoke.getMockImplementation()!; + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'detect_ollama') throw new Error('down'); + if (cmd === 'get_models_dir_free_bytes') throw new Error('down'); + return base(cmd, args); + }); + + const { container } = renderBuiltin(); + await act(async () => {}); + + expect( + screen.queryByText('Use my existing Ollama instead'), + ).not.toBeInTheDocument(); + + const balancedCard = container.querySelector('[data-tier="balanced"]')!; + await act(async () => { + fireEvent.click( + within(balancedCard as HTMLElement).getByRole('button', { + name: 'Download', + }), + ); + }); + expect(screen.getByText('7.3 GB download.')).toBeInTheDocument(); + expect(screen.queryByText(/free on this disk/)).not.toBeInTheDocument(); + }); + + it('cancel from the confirm card returns to the picker', async () => { + // A null free-bytes answer (backend could not stat the volume) hides + // the disk line instead of blocking the flow. + builtinResponses({ get_models_dir_free_bytes: null }); + + const { container } = renderBuiltin(); + await act(async () => {}); + + const fastCard = container.querySelector('[data-tier="fast"]')!; + await act(async () => { + fireEvent.click( + within(fastCard as HTMLElement).getByRole('button', { + name: 'Download', + }), + ); + }); + // 'fast' fits comfortably: no RAM warning inside the confirm card (the + // picker badge keeps its single copy), and the null free-bytes answer + // drops the disk line. + expect(screen.getAllByText('Runs comfortably on this Mac')).toHaveLength(1); + expect(screen.queryByText(/free on this disk/)).not.toBeInTheDocument(); + + const progressCard = container.querySelector('[data-download-progress]')!; + await act(async () => { + fireEvent.click( + within(progressCard as HTMLElement).getByRole('button', { + name: 'Cancel', + }), + ); + }); + expect(screen.queryByText('7.3 GB download.')).not.toBeInTheDocument(); + expect(screen.getByText('Model balanced')).toBeInTheDocument(); + }); + + it('cancel during download invokes cancel_model_download and returns to the picker', async () => { + builtinResponses({ cancel_model_download: undefined }); + + const { container } = renderBuiltin(); + await act(async () => {}); + await startDownload(container as HTMLElement, 'balanced'); + + const channel = getLastChannel()!; + await act(async () => { + channel.simulateMessage({ + type: 'Started', + data: { file: 'balanced.gguf', total_bytes: 100, resumed_from: 0 }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + }); + expect(invoke).toHaveBeenCalledWith('cancel_model_download'); + + await act(async () => { + channel.simulateMessage({ type: 'Cancelled' }); + }); + expect(screen.getByText('Model balanced')).toBeInTheDocument(); + }); + + it('shows resume and discard when an option carries a resumable partial', async () => { + const withPartial = [ + makeOption('fast'), + makeOption('balanced', { fit: 'tight', partial_bytes: 1_200_000_000 }), + makeOption('smartest'), + ]; + builtinResponses({ get_starter_options: withPartial }); + + renderBuiltin(); + await act(async () => {}); + + const resume = screen.getByRole('button', { + name: 'Resume download (1.2 of 7.3 GB)', + }); + await act(async () => { + fireEvent.click(resume); + }); + expect(invoke).toHaveBeenCalledWith( + 'download_starter', + expect.objectContaining({ tier: 'balanced' }), + ); + }); + + it('discard invokes discard_partial_download and refreshes the options', async () => { + const withPartial = [ + makeOption('fast'), + makeOption('balanced', { partial_bytes: 1_200_000_000 }), + makeOption('smartest'), + ]; + builtinResponses({ + get_starter_options: withPartial, + discard_partial_download: undefined, + }); + + renderBuiltin(); + await act(async () => {}); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + }); + expect(invoke).toHaveBeenCalledWith('discard_partial_download', { + sha256: 'b'.repeat(64), + }); + await waitFor(() => { + expect( + invoke.mock.calls.filter((c) => c[0] === 'get_starter_options'), + ).toHaveLength(2); + }); + }); + + it('escape hatch from the picker switches the provider and lands in the legacy flow', async () => { + builtinResponses({ set_active_provider: undefined }); + + renderBuiltin(); + await act(async () => {}); + + await act(async () => { + fireEvent.click(screen.getByText('Use my existing Ollama instead')); + }); + + expect(invoke).toHaveBeenCalledWith('set_active_provider', { + providerId: 'ollama', + }); + // No download in flight from the picker: nothing to cancel. + expect(invoke).not.toHaveBeenCalledWith('cancel_model_download'); + // The legacy machine renders (its Verify button does not exist in the + // builtin flow). + expect(screen.getByLabelText('Verify setup')).toBeInTheDocument(); + expect(screen.getByText('Install & start Ollama')).toBeInTheDocument(); + }); + + it('escape hatch during a download cancels it before switching', async () => { + builtinResponses({ + set_active_provider: undefined, + cancel_model_download: undefined, + }); + + const { container } = renderBuiltin(); + await act(async () => {}); + await startDownload(container as HTMLElement, 'balanced'); + + const channel = getLastChannel()!; + await act(async () => { + channel.simulateMessage({ + type: 'Started', + data: { file: 'balanced.gguf', total_bytes: 100, resumed_from: 0 }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Use my existing Ollama instead')); + }); + + expect(invoke).toHaveBeenCalledWith('cancel_model_download'); + expect(invoke).toHaveBeenCalledWith('set_active_provider', { + providerId: 'ollama', + }); + expect(screen.getByLabelText('Verify setup')).toBeInTheDocument(); + }); + + it('escape hatch is hidden during a download when Ollama is not detected', async () => { + builtinResponses({ detect_ollama: false }); + + const { container } = renderBuiltin(); + await act(async () => {}); + await startDownload(container as HTMLElement, 'balanced'); + + const channel = getLastChannel()!; + await act(async () => { + channel.simulateMessage({ + type: 'Started', + data: { file: 'balanced.gguf', total_bytes: 100, resumed_from: 0 }, + }); + }); + + expect( + screen.queryByText('Use my existing Ollama instead'), + ).not.toBeInTheDocument(); + }); + + it('stays on the builtin flow when switching the provider fails', async () => { + builtinResponses(); + const base = invoke.getMockImplementation()!; + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'set_active_provider') throw new Error('disk error'); + return base(cmd, args); + }); + + renderBuiltin(); + await act(async () => {}); + + await act(async () => { + fireEvent.click(screen.getByText('Use my existing Ollama instead')); + }); + + expect(screen.queryByLabelText('Verify setup')).not.toBeInTheDocument(); + expect(screen.getByText('Model balanced')).toBeInTheDocument(); + }); + + it('failure shows the failed card with the escape hatch; retry restarts the download', async () => { + builtinResponses(); + + const { container } = renderBuiltin(); + await act(async () => {}); + await startDownload(container as HTMLElement, 'balanced'); + + const channel = getLastChannel()!; + await act(async () => { + channel.simulateMessage({ + type: 'Failed', + data: { kind: 'offline', message: 'no network' }, + }); + }); + + expect(screen.getByText('You appear to be offline.')).toBeInTheDocument(); + expect( + screen.getByText('Use my existing Ollama instead'), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + }); + expect( + invoke.mock.calls.filter((c) => c[0] === 'download_starter'), + ).toHaveLength(2); + }); + + it('drops probe results that resolve after unmount', async () => { + let resolveSetup: (v: unknown) => void = () => {}; + let resolveDetect: (v: unknown) => void = () => {}; + let resolveFree: (v: unknown) => void = () => {}; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'check_model_setup') { + return new Promise((r) => { + resolveSetup = r; + }); + } + if (cmd === 'detect_ollama') { + return new Promise((r) => { + resolveDetect = r; + }); + } + if (cmd === 'get_models_dir_free_bytes') { + return new Promise((r) => { + resolveFree = r; + }); + } + if (cmd === 'get_starter_options') return BUILTIN_OPTIONS; + return undefined; + }); + + const { unmount } = renderBuiltin(); + await act(async () => {}); + unmount(); + + await act(async () => { + resolveSetup(READY_RESPONSE); + resolveDetect(true); + resolveFree(1); + }); + + expect(invoke).not.toHaveBeenCalledWith('advance_past_model_check'); + }); +}); + +describe('buildConfirmInfo', () => { + it('returns undefined outside the confirming phase', () => { + expect(buildConfirmInfo({ phase: 'idle' }, BUILTIN_OPTIONS, null)).toBe( + undefined, + ); + }); + + it('returns undefined when the confirming tier has no option row', () => { + expect( + buildConfirmInfo({ phase: 'confirming', tier: 'balanced' }, [], null), + ).toBe(undefined); + }); + + it('maps size, free disk, and the RAM caution for a non-fits tier', () => { + expect( + buildConfirmInfo( + { phase: 'confirming', tier: 'smartest' }, + BUILTIN_OPTIONS, + 20_000_000_000, + ), + ).toEqual({ + sizeGb: 7.3, + freeDiskGb: 20, + ramWarning: + "Larger than this Mac's memory can comfortably hold. Expect heavy slowdown.", + }); + }); + + it('hides the disk line and the warning for a comfortable fit', () => { + expect( + buildConfirmInfo( + { phase: 'confirming', tier: 'fast' }, + BUILTIN_OPTIONS, + null, + ), + ).toEqual({ sizeGb: 7.3, freeDiskGb: null, ramWarning: null }); + }); +}); From ce1b412af002e2f0f6533201cd3d9c44a219e85c Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 11 Jun 2026 22:39:47 -0400 Subject: [PATCH 03/51] feat: rework Settings into a full Providers panel Signed-off-by: Logan Nguyen --- docs/configurations.md | 1 + src-tauri/src/config/defaults.rs | 11 + src-tauri/src/lib.rs | 5 + src-tauri/src/models/mod.rs | 328 ++++- src-tauri/src/settings_commands.rs | 302 ++++- src-tauri/src/settings_commands/tests.rs | 389 +++++- src/hooks/__tests__/useDownloadModel.test.tsx | 45 +- src/hooks/useDownloadModel.ts | 47 +- src/settings/configHelpers.ts | 10 + src/settings/tabs/ModelTab.tsx | 482 +++++-- src/settings/tabs/ProviderCards.test.tsx | 1175 +++++++++++++++++ src/settings/tabs/ProviderCards.tsx | 740 +++++++++++ src/settings/tabs/tabs.test.tsx | 297 ++++- src/styles/settings.module.css | 54 + src/types/starter.ts | 18 + 15 files changed, 3735 insertions(+), 169 deletions(-) create mode 100644 src/settings/tabs/ProviderCards.test.tsx create mode 100644 src/settings/tabs/ProviderCards.tsx diff --git a/docs/configurations.md b/docs/configurations.md index 61a1c39c..282b7963 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -181,6 +181,7 @@ The table below also lists the baked-in safety limits that govern Thuki's commun | `MAX_HF_API_BODY_BYTES` | `4 MiB` | No | Defense-in-depth bound on attacker-controlled data from a remote service, mirroring `MAX_OLLAMA_TAGS_BODY_BYTES`. | — | The largest Hugging Face API response body (repo file listings) Thuki will accept while resolving a model to download. Larger responses are rejected mid-stream and the request returns an error. | | `HF_API_TIMEOUT_SECS` | `15 s` | No | Protocol cap on a hung remote service so the download UI cannot stall on metadata resolution; 15 s is generous for a small metadata call over the internet. | — | How long Thuki waits for a Hugging Face API metadata call (repo file listing) to respond before giving up. Applies to resolving pasted repo ids and listing a repo's GGUF files, not to the model download itself. | | `HF_BASE_URL` | `https://huggingface.co` | No | Single origin for model metadata and downloads; the sha256-pinning and provenance model assume the canonical Hub. Pointing downloads at an arbitrary mirror would bypass the integrity guarantees that make the curated starter registry safe. | — | The Hugging Face origin Thuki uses for all model metadata calls and blob downloads. Every starter in the registry pins a repo at an exact revision and carries a sha256 digest verified on install; those digests are read from this origin and only meaningful against it. | +| `OPENAI_MODELS_TIMEOUT_SECS` | `5 s` | No | Protocol cap on a hung server so the Settings model dropdown cannot stall; the OpenAI-compatible server is local or LAN-hosted in the common case, so 5 s is generous. | — | How long Thuki waits for an OpenAI-compatible server's `/v1/models` listing to respond before giving up. Applies to the Settings model dropdown for that provider, not to chat requests. | | `MAX_SSE_LINE_BYTES` | `1 MiB` | No | Defense-in-depth bound on attacker-controlled stream data. A malicious or broken chat server could otherwise grow a single stream line without limit and exhaust memory. | — | The longest single Server-Sent-Events line Thuki accepts while streaming a chat response from an OpenAI-compatible (`/v1`) server. A stream line exceeding this aborts the response with an error. | ### `[prompt]` diff --git a/src-tauri/src/config/defaults.rs b/src-tauri/src/config/defaults.rs index 8b0b2698..7f7a5f1a 100644 --- a/src-tauri/src/config/defaults.rs +++ b/src-tauri/src/config/defaults.rs @@ -12,6 +12,9 @@ pub const DEFAULT_OLLAMA_URL: &str = "http://127.0.0.1:11434"; /// Stable provider ids. `active_provider` references one of these. pub const PROVIDER_ID_BUILTIN: &str = "builtin"; pub const PROVIDER_ID_OLLAMA: &str = "ollama"; +/// Fixed id of the (at most one) OpenAI-compatible provider record. A single +/// record mirrors the single Ollama URL: one external server at a time. +pub const PROVIDER_ID_OPENAI: &str = "openai"; /// Provider kinds understood by the loader. Providers with any other kind are /// dropped during resolution. Recognized kinds: `"builtin"`, `"ollama"`, @@ -27,6 +30,8 @@ pub const PROVIDER_KIND_OPENAI: &str = "openai"; /// Human-readable provider labels shown in Settings. pub const DEFAULT_BUILTIN_LABEL: &str = "Built-in (Thuki)"; pub const DEFAULT_OLLAMA_LABEL: &str = "Ollama"; +/// Fallback label for an OpenAI-compatible provider added with no label. +pub const DEFAULT_OPENAI_LABEL: &str = "OpenAI-compatible"; /// Provider Thuki sends inference to on a fresh install. /// @@ -369,6 +374,12 @@ pub const MAX_HF_API_BODY_BYTES: usize = 4 * 1024 * 1024; /// Per-request timeout (seconds) for Hugging Face API metadata calls. pub const HF_API_TIMEOUT_SECS: u64 = 15; +/// Per-request timeout (seconds) for an OpenAI-compatible server's +/// `/v1/models` listing. Tighter than the Hugging Face timeout because the +/// server is local or LAN-hosted in the common case and the Settings model +/// dropdown blocks on this probe. +pub const OPENAI_MODELS_TIMEOUT_SECS: u64 = 5; + /// Canonical Hugging Face origin used for both model metadata calls and blob /// downloads. Not user-tunable: the sha256-pinning + provenance model assumes /// the canonical Hub; pointing downloads at an arbitrary mirror would bypass diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79495743..757ed753 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2051,6 +2051,9 @@ pub fn run() { settings_commands::set_config_field, settings_commands::set_ollama_url, settings_commands::set_active_provider, + settings_commands::update_provider_field, + settings_commands::add_openai_provider, + settings_commands::remove_openai_provider, settings_commands::reset_config, settings_commands::reload_config_from_disk, settings_commands::get_corrupt_marker, @@ -2079,6 +2082,8 @@ pub fn run() { #[cfg(not(coverage))] models::list_hf_repo_ggufs, #[cfg(not(coverage))] + models::list_openai_models, + #[cfg(not(coverage))] models::cancel_model_download, #[cfg(not(coverage))] models::discard_partial_download, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 2539519e..f388fc06 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -30,8 +30,8 @@ use tauri::Manager; use crate::config::defaults::{ DEFAULT_OLLAMA_SHOW_REQUEST_TIMEOUT_SECS, DEFAULT_OLLAMA_TAGS_REQUEST_TIMEOUT_SECS, HF_API_TIMEOUT_SECS, HF_BASE_URL, MAX_HF_API_BODY_BYTES, MAX_MODEL_SLUG_LEN, - MAX_OLLAMA_SHOW_BODY_BYTES, MAX_OLLAMA_TAGS_BODY_BYTES, PROVIDER_ID_BUILTIN, - PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, PROVIDER_KIND_OPENAI, + MAX_OLLAMA_SHOW_BODY_BYTES, MAX_OLLAMA_TAGS_BODY_BYTES, OPENAI_MODELS_TIMEOUT_SECS, + PROVIDER_ID_BUILTIN, PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, PROVIDER_KIND_OPENAI, }; use crate::config::AppConfig; @@ -1378,6 +1378,116 @@ pub async fn fetch_repo_gguf_listing( parse_gguf_listing(&body) } +// ─── OpenAI-compatible model listing ───────────────────────────────────────── + +/// Subset of an OpenAI-compatible `/v1/models` response Thuki consumes. +#[derive(Deserialize)] +struct OpenAiModelsResponse { + #[serde(default)] + data: Vec, +} + +/// One model row in the `/v1/models` listing. +#[derive(Deserialize)] +struct OpenAiModelEntry { + #[serde(default)] + id: String, +} + +/// Pure parse of a `/v1/models` body into model ids. Rows with an empty or +/// missing `id` are dropped rather than surfaced as blank dropdown entries. +pub fn parse_openai_models(body: &[u8]) -> Result, String> { + let parsed: OpenAiModelsResponse = serde_json::from_slice(body) + .map_err(|e| format!("failed to decode /v1/models response: {e}"))?; + Ok(parsed + .data + .into_iter() + .map(|m| m.id) + .filter(|id| !id.is_empty()) + .collect()) +} + +/// The configured OpenAI-compatible provider's `(id, base_url)`. Errors when +/// no `openai`-kind provider exists so the UI shows a stable message instead +/// of probing an empty URL. +pub fn openai_provider_target(config: &AppConfig) -> Result<(String, String), String> { + config + .inference + .providers + .iter() + .find(|p| p.kind == PROVIDER_KIND_OPENAI) + .map(|p| (p.id.clone(), p.base_url.clone())) + .ok_or_else(|| "no OpenAI-compatible provider is configured".to_string()) +} + +/// GETs `/v1/models` with the production timeout and body cap and +/// returns the listed model ids. `api_key` is sent as a bearer token when +/// present (keyless local servers are common, so it is optional). +pub async fn fetch_openai_models( + client: &reqwest::Client, + base_url: &str, + api_key: Option<&str>, +) -> Result, String> { + fetch_openai_models_inner( + client, + base_url, + api_key, + std::time::Duration::from_secs(OPENAI_MODELS_TIMEOUT_SECS), + MAX_HF_API_BODY_BYTES, + ) + .await +} + +/// Innermost `/v1/models` fetcher with timeout and body cap configurable so +/// the cap branches are testable. The cap is enforced incrementally during +/// the streaming read, mirroring [`fetch_installed_model_names_inner`]. +async fn fetch_openai_models_inner( + client: &reqwest::Client, + base_url: &str, + api_key: Option<&str>, + timeout: std::time::Duration, + max_body_bytes: usize, +) -> Result, String> { + let url = format!("{}/v1/models", base_url.trim_end_matches('/')); + let mut request = client.get(&url).timeout(timeout); + if let Some(key) = api_key { + request = request.bearer_auth(key); + } + let response = request + .send() + .await + .map_err(|e| format!("failed to reach the server: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "/v1/models returned HTTP {}", + response.status().as_u16() + )); + } + + if let Some(declared_len) = response.content_length() { + if declared_len as usize > max_body_bytes { + return Err(format!( + "/v1/models response exceeded {max_body_bytes} bytes" + )); + } + } + + let mut stream = response.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("failed to read /v1/models body: {e}"))?; + if buf.len() + chunk.len() > max_body_bytes { + return Err(format!( + "/v1/models response exceeded {max_body_bytes} bytes" + )); + } + buf.extend_from_slice(&chunk); + } + + parse_openai_models(&buf) +} + /// Download specs for a resolved repo model: weights first, then the mmproj /// companion. URL shape matches [`registry::download_specs`]: /// `//resolve//`. @@ -1599,6 +1709,22 @@ pub async fn list_hf_repo_ggufs( fetch_repo_gguf_listing(&client, HF_BASE_URL, &repo).await } +/// Lists the models served by the configured OpenAI-compatible provider via +/// its `/v1/models` endpoint, using the Keychain API key when one is stored. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub async fn list_openai_models( + config: tauri::State<'_, parking_lot::RwLock>, + secrets: tauri::State<'_, crate::keychain::Secrets>, + client: tauri::State<'_, reqwest::Client>, +) -> Result, String> { + let (provider_id, base_url) = openai_provider_target(&config.read())?; + // A Keychain read failure degrades to "no key": keyless local servers + // must keep listing even when the Keychain is unavailable. + let api_key = secrets.0.get(&provider_id).ok().flatten(); + fetch_openai_models(&client, &base_url, api_key.as_deref()).await +} + /// Cancels the in-flight model download, if any. The download task emits /// `Cancelled` and keeps the partial for a later resume. #[cfg_attr(coverage_nightly, coverage(off))] @@ -2203,6 +2329,204 @@ mod tests { ); } + // ── OpenAI-compatible model listing ────────────────────────────────────── + + #[test] + fn parse_openai_models_extracts_ids_and_drops_blank_rows() { + let body = br#"{"object":"list","data":[ + {"id":"llama-3.1-8b","object":"model"}, + {"id":"","object":"model"}, + {"object":"model"}, + {"id":"qwen2.5-7b"} + ]}"#; + assert_eq!( + parse_openai_models(body).unwrap(), + vec!["llama-3.1-8b".to_string(), "qwen2.5-7b".to_string()] + ); + } + + #[test] + fn parse_openai_models_tolerates_missing_data_field() { + assert_eq!(parse_openai_models(b"{}").unwrap(), Vec::::new()); + } + + #[test] + fn parse_openai_models_maps_malformed_json_to_err() { + let err = parse_openai_models(b"not json").unwrap_err(); + assert!(err.contains("failed to decode /v1/models response")); + } + + #[test] + fn openai_provider_target_returns_id_and_base_url() { + let mut cfg = AppConfig::default(); + cfg.inference + .providers + .push(crate::config::schema::openai_provider( + "openai", + "LM Studio", + "http://127.0.0.1:1234", + )); + assert_eq!( + openai_provider_target(&cfg).unwrap(), + ("openai".to_string(), "http://127.0.0.1:1234".to_string()) + ); + } + + #[test] + fn openai_provider_target_errors_when_absent() { + let cfg = AppConfig::default(); + let err = openai_provider_target(&cfg).unwrap_err(); + assert!(err.contains("no OpenAI-compatible provider")); + } + + #[tokio::test] + async fn fetch_openai_models_sends_bearer_key_and_parses_ids() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/v1/models") + .match_header("authorization", "Bearer sk-test") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"data":[{"id":"m1"},{"id":"m2"}]}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = fetch_openai_models(&client, &server.url(), Some("sk-test")).await; + + mock.assert_async().await; + assert_eq!(result.unwrap(), vec!["m1".to_string(), "m2".to_string()]); + } + + #[tokio::test] + async fn fetch_openai_models_omits_authorization_without_key() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/v1/models") + .match_header("authorization", mockito::Matcher::Missing) + .with_status(200) + .with_body(r#"{"data":[{"id":"m1"}]}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + // Trailing slash also exercises the base-url trim. + let base = format!("{}/", server.url()); + let result = fetch_openai_models(&client, &base, None).await; + + mock.assert_async().await; + assert_eq!(result.unwrap(), vec!["m1".to_string()]); + } + + #[tokio::test] + async fn fetch_openai_models_maps_http_error_to_err_string() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/v1/models") + .with_status(401) + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = fetch_openai_models(&client, &server.url(), None) + .await + .unwrap_err(); + assert!(err.contains("/v1/models returned HTTP 401"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_openai_models_maps_transport_error_to_err_string() { + // Bind then drop a listener so the port is closed. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + + let client = reqwest::Client::new(); + let err = fetch_openai_models(&client, &format!("http://{addr}"), None) + .await + .unwrap_err(); + assert!(err.contains("failed to reach the server"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_openai_models_rejects_body_exceeding_cap_via_content_length() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/v1/models") + .with_status(200) + .with_body("x".repeat(100)) + .create_async() + .await; + + let client = reqwest::Client::new(); + let err = fetch_openai_models_inner( + &client, + &server.url(), + None, + std::time::Duration::from_secs(5), + 32, + ) + .await + .unwrap_err(); + assert!(err.contains("exceeded"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_openai_models_rejects_body_exceeding_cap_when_no_content_length() { + // Chunked response (no Content-Length); the incremental stream cap + // must reject when the running total exceeds the limit. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + use std::io::{Read, Write}; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\ + 0a\r\n0123456789\r\n\ + 0a\r\n0123456789\r\n\ + 0a\r\n0123456789\r\n\ + 0\r\n\r\n", + ); + }); + + let client = reqwest::Client::new(); + let err = fetch_openai_models_inner( + &client, + &format!("http://{addr}"), + None, + std::time::Duration::from_secs(5), + 20, + ) + .await + .unwrap_err(); + assert!(err.contains("exceeded"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_openai_models_maps_body_read_error_to_err_string() { + // Headers advertise Content-Length but the server hangs up before + // sending the body, so the streaming read fails mid-flight. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + use std::io::{Read, Write}; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n", + ); + }); + + let client = reqwest::Client::new(); + let err = fetch_openai_models(&client, &format!("http://{addr}"), None) + .await + .unwrap_err(); + assert!(err.contains("failed to read /v1/models body"), "got: {err}"); + } + // ── ActiveModelState ───────────────────────────────────────────────────── #[test] diff --git a/src-tauri/src/settings_commands.rs b/src-tauri/src/settings_commands.rs index e474fdbe..bc89f00b 100644 --- a/src-tauri/src/settings_commands.rs +++ b/src-tauri/src/settings_commands.rs @@ -94,6 +94,14 @@ fn is_allowed_section(section: &str) -> bool { ALLOWED_SECTIONS.contains(§ion) } +/// True when `url` is an absolute http(s) URL. Same rule as the loader's +/// private `is_http_url`: provider base URLs the backend will POST to must +/// be rejected at write time rather than silently dropped at the next load. +pub(crate) fn is_http_url(url: &str) -> bool { + let url = url.trim(); + url.starts_with("http://") || url.starts_with("https://") +} + /// Returns true when the post-write `AppConfig` flips `[debug] trace_enabled` /// relative to the pre-write snapshot. Pulled out so the predicate is /// covered by tests instead of riding inside the coverage-off Tauri command @@ -301,6 +309,98 @@ pub fn set_active_provider( Ok(resolved) } +/// Patches one field (`model`, `base_url`, `label`, or `vision`) on the +/// provider whose id is `provider_id` and returns the resolved `AppConfig`. +/// +/// Generalizes `set_ollama_url` to every editable provider field. A `model` +/// write on the active provider also re-mirrors the in-memory +/// [`crate::models::ActiveModelState`] so chat routes to the new selection +/// without a restart. Mirrors `set_ollama_url`'s lock + persist + broadcast +/// contract. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn update_provider_field( + provider_id: String, + field: String, + value: String, + app: AppHandle, + state: State<'_, RwLock>, + active_model: State<'_, crate::models::ActiveModelState>, +) -> Result { + let path = config_path(&app)?; + let resolved = { + let mut guard = state.write(); + let resolved = write_provider_field_to_disk(&path, &provider_id, &field, &value)?; + *guard = resolved.clone(); + resolved + }; + if field == "model" { + if let Some(mirror) = crate::models::should_refresh_active_model(&provider_id, &resolved) { + if let Ok(mut guard) = active_model.0.lock() { + *guard = mirror; + } + } + } + emit_config_updated(&app); + Ok(resolved) +} + +/// Adds the single OpenAI-compatible provider (fixed id `"openai"`) and +/// returns the resolved `AppConfig`. Empty label falls back to the compiled +/// default. Mirrors `set_ollama_url`'s lock + persist + broadcast contract. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn add_openai_provider( + label: String, + base_url: String, + app: AppHandle, + state: State<'_, RwLock>, +) -> Result { + let path = config_path(&app)?; + let resolved = { + let mut guard = state.write(); + let resolved = add_openai_provider_to_disk(&path, &label, &base_url)?; + *guard = resolved.clone(); + resolved + }; + emit_config_updated(&app); + Ok(resolved) +} + +/// Removes the OpenAI-compatible provider and returns the resolved +/// `AppConfig`. When it was active, the active pointer falls back to the +/// built-in provider in the same atomic edit. Best-effort cleanup: the +/// provider's Keychain API key is deleted (a Keychain failure never undoes +/// the config removal), and the in-memory active-model mirror is refreshed +/// onto whatever provider is active after the removal. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn remove_openai_provider( + app: AppHandle, + state: State<'_, RwLock>, + active_model: State<'_, crate::models::ActiveModelState>, + secrets: State<'_, crate::keychain::Secrets>, +) -> Result { + let path = config_path(&app)?; + let resolved = { + let mut guard = state.write(); + let resolved = remove_openai_provider_from_disk(&path)?; + *guard = resolved.clone(); + resolved + }; + let _ = secrets + .0 + .delete(crate::config::defaults::PROVIDER_ID_OPENAI); + let active_id = resolved.inference.active_provider.clone(); + if let Some(mirror) = crate::models::should_refresh_active_model(&active_id, &resolved) { + if let Ok(mut guard) = active_model.0.lock() { + *guard = mirror; + } + } + emit_config_updated(&app); + Ok(resolved) +} + /// Persists `[inference] active_provider = provider_id` after validating that /// the id names an entry in the on-disk `[[inference.providers]]` list, /// preserving the rest of the file via `toml_edit`, then reloads + resolves. @@ -342,19 +442,20 @@ pub(crate) fn write_active_provider_to_disk( config::load_from_path(path) } -/// Patches a single field (`model` or `base_url`) on the +/// Patches a single field (`model`, `base_url`, `label`, or `vision`) on the /// `[[inference.providers]]` entry whose `id` matches `provider_id`, preserving /// the rest of the file via `toml_edit`, then reloads + resolves. Backs the -/// `set_active_model` (model) and `set_ollama_url` (base_url) write paths. -/// Pulled out of the Tauri wrappers so the field allowlist, table lookup, -/// atomic write, and post-write reload are exercised without an `AppHandle`. +/// `set_active_model` (model), `set_ollama_url` (base_url), and +/// `update_provider_field` write paths. Pulled out of the Tauri wrappers so +/// the field allowlist, per-field validation, table lookup, atomic write, and +/// post-write reload are exercised without an `AppHandle`. pub(crate) fn write_provider_field_to_disk( path: &Path, provider_id: &str, field: &str, value: &str, ) -> Result { - if !matches!(field, "model" | "base_url") { + if !matches!(field, "model" | "base_url" | "label" | "vision") { return Err(ConfigError::UnknownField { section: "inference.providers".to_string(), key: field.to_string(), @@ -373,7 +474,13 @@ pub(crate) fn write_provider_field_to_disk( let mut patched = false; for table in providers.iter_mut() { if table.get("id").and_then(|v| v.as_str()) == Some(provider_id) { - table.insert(field, toml_value(value)); + let kind = table + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let item = validate_provider_value(&kind, field, value)?; + table.insert(field, item); patched = true; break; } @@ -393,6 +500,189 @@ pub(crate) fn write_provider_field_to_disk( config::load_from_path(path) } +/// Validates and coerces one provider field value into a TOML item. +/// +/// Per-field rules: +/// - `model`: free-form string, trimmed. +/// - `label`: trimmed; a trimmed-empty value on an `openai`-kind provider +/// heals to the compiled default label, mirroring the add path so the card +/// heading never renders blank. +/// - `base_url`: rejected for the built-in provider (it has no URL); must be +/// an absolute http(s) URL for the network kinds. +/// - `vision`: the strings `"true"` / `"false"`, stored as a TOML boolean so +/// the schema's typed `bool` round-trips. +/// +/// Validation errors come back as `TypeMismatch` whose message the Settings +/// UI surfaces verbatim in the inline error pill. +pub(crate) fn validate_provider_value( + kind: &str, + field: &str, + value: &str, +) -> Result { + let mismatch = |message: &str| ConfigError::TypeMismatch { + section: "inference.providers".to_string(), + key: field.to_string(), + message: message.to_string(), + }; + match field { + "model" => Ok(toml_value(value.trim())), + "label" => { + let trimmed = value.trim(); + if trimmed.is_empty() && kind == crate::config::defaults::PROVIDER_KIND_OPENAI { + // Mirrors `add_openai_provider_to_disk`: an empty label heals + // to the compiled default instead of persisting a blank + // heading. + return Ok(toml_value(crate::config::defaults::DEFAULT_OPENAI_LABEL)); + } + Ok(toml_value(trimmed)) + } + "base_url" => { + if kind == crate::config::defaults::PROVIDER_KIND_BUILTIN { + return Err(mismatch("The built-in provider has no base URL.")); + } + if !is_http_url(value) { + return Err(mismatch("Base URL must start with http:// or https://.")); + } + Ok(toml_value(value.trim())) + } + "vision" => match value { + "true" => Ok(toml_value(true)), + "false" => Ok(toml_value(false)), + _ => Err(mismatch("vision must be \"true\" or \"false\".")), + }, + other => Err(ConfigError::UnknownField { + section: "inference.providers".to_string(), + key: other.to_string(), + }), + } +} + +/// Appends the single OpenAI-compatible provider record to the on-disk +/// `[[inference.providers]]` array, then reloads + resolves. At most one +/// `openai`-kind record may exist (fixed id `"openai"`, mirroring the single +/// Ollama URL); a second add is rejected. An empty `label` falls back to +/// [`crate::config::defaults::DEFAULT_OPENAI_LABEL`]. Pulled out of the Tauri +/// wrapper so the validation, duplicate guard, atomic write, and post-write +/// reload are exercised without an `AppHandle`. +pub(crate) fn add_openai_provider_to_disk( + path: &Path, + label: &str, + base_url: &str, +) -> Result { + use crate::config::defaults::{DEFAULT_OPENAI_LABEL, PROVIDER_ID_OPENAI, PROVIDER_KIND_OPENAI}; + + if !is_http_url(base_url) { + return Err(ConfigError::TypeMismatch { + section: "inference.providers".to_string(), + key: "base_url".to_string(), + message: "Base URL must start with http:// or https://.".to_string(), + }); + } + let mut doc = read_document(path)?; + let providers = doc + .get_mut("inference") + .and_then(|i| i.get_mut("providers")) + .and_then(|p| p.as_array_of_tables_mut()); + let Some(providers) = providers else { + return Err(ConfigError::UnknownSection { + section: "inference.providers".to_string(), + }); + }; + let already_exists = providers + .iter() + .any(|t| t.get("kind").and_then(|v| v.as_str()) == Some(PROVIDER_KIND_OPENAI)); + if already_exists { + return Err(ConfigError::TypeMismatch { + section: "inference.providers".to_string(), + key: PROVIDER_ID_OPENAI.to_string(), + message: "An OpenAI-compatible provider already exists.".to_string(), + }); + } + let label = label.trim(); + let label = if label.is_empty() { + DEFAULT_OPENAI_LABEL + } else { + label + }; + // The typed constructor is the single source of truth for the record's + // shape (kind, empty model, vision off); this just transcribes it to TOML. + let provider = + crate::config::schema::openai_provider(PROVIDER_ID_OPENAI, label, base_url.trim()); + let mut table = Table::new(); + table.insert("id", toml_value(provider.id.as_str())); + table.insert("kind", toml_value(provider.kind.as_str())); + table.insert("label", toml_value(provider.label.as_str())); + table.insert("base_url", toml_value(provider.base_url.as_str())); + table.insert("model", toml_value(provider.model.as_str())); + table.insert("vision", toml_value(provider.vision)); + providers.push(table); + + config::atomic_write_bytes(path, doc.to_string().as_bytes()).map_err(|source| { + ConfigError::IoError { + path: path.to_path_buf(), + source, + } + })?; + config::load_from_path(path) +} + +/// Removes the `openai`-kind entry from the on-disk `[[inference.providers]]` +/// array. When the removed provider was active, `active_provider` falls back +/// to the built-in provider in the same atomic edit. Errors when no +/// OpenAI-compatible provider exists. Pulled out of the Tauri wrapper so the +/// removal, fallback, atomic write, and post-write reload are exercised +/// without an `AppHandle`. +pub(crate) fn remove_openai_provider_from_disk(path: &Path) -> Result { + use crate::config::defaults::{PROVIDER_ID_BUILTIN, PROVIDER_ID_OPENAI, PROVIDER_KIND_OPENAI}; + + let mut doc = read_document(path)?; + let providers = doc + .get_mut("inference") + .and_then(|i| i.get_mut("providers")) + .and_then(|p| p.as_array_of_tables_mut()); + let Some(providers) = providers else { + return Err(ConfigError::UnknownSection { + section: "inference.providers".to_string(), + }); + }; + let removed_ids: Vec = providers + .iter() + .filter(|t| t.get("kind").and_then(|v| v.as_str()) == Some(PROVIDER_KIND_OPENAI)) + .map(|t| { + t.get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() + }) + .collect(); + if removed_ids.is_empty() { + return Err(ConfigError::UnknownField { + section: "inference.providers".to_string(), + key: PROVIDER_ID_OPENAI.to_string(), + }); + } + providers.retain(|t| t.get("kind").and_then(|v| v.as_str()) != Some(PROVIDER_KIND_OPENAI)); + + let active_removed = doc + .get("inference") + .and_then(|i| i.get("active_provider")) + .and_then(|v| v.as_str()) + .is_some_and(|active| removed_ids.iter().any(|id| id == active)); + if active_removed { + if let Some(table) = doc.get_mut("inference").and_then(Item::as_table_mut) { + table.insert("active_provider", toml_value(PROVIDER_ID_BUILTIN)); + } + } + + config::atomic_write_bytes(path, doc.to_string().as_bytes()).map_err(|source| { + ConfigError::IoError { + path: path.to_path_buf(), + source, + } + })?; + config::load_from_path(path) +} + /// Resets one section (or the whole file when `section` is `None`) to the /// compiled defaults, returning the resulting `AppConfig`. /// diff --git a/src-tauri/src/settings_commands/tests.rs b/src-tauri/src/settings_commands/tests.rs index 1069548e..0af2e979 100644 --- a/src-tauri/src/settings_commands/tests.rs +++ b/src-tauri/src/settings_commands/tests.rs @@ -12,10 +12,11 @@ use serde_json::json; use toml_edit::DocumentMut; use super::{ - coerce_json_to_toml, idle_unload_minutes_changed, is_allowed_field, is_allowed_section, - json_type_name, json_value_to_toml_item, patch_document, read_document, reset_section_on_disk, - trace_enabled_changed, write_active_provider_to_disk, write_field_to_disk, - write_provider_field_to_disk, + add_openai_provider_to_disk, coerce_json_to_toml, idle_unload_minutes_changed, + is_allowed_field, is_allowed_section, is_http_url, json_type_name, json_value_to_toml_item, + patch_document, read_document, remove_openai_provider_from_disk, reset_section_on_disk, + trace_enabled_changed, validate_provider_value, write_active_provider_to_disk, + write_field_to_disk, write_provider_field_to_disk, }; use crate::config::defaults::{ALLOWED_FIELDS, ALLOWED_SECTIONS}; use crate::config::{AppConfig, ConfigError}; @@ -79,6 +80,36 @@ base_url = "http://127.0.0.1:11434" model = "" "#; +/// PROVIDERS_CONFIG plus an OpenAI-compatible entry, for the add/remove/update +/// provider tests. +const OPENAI_PROVIDERS_CONFIG: &str = r#" +[inference] +active_provider = "ollama" +num_ctx = 16384 +keep_warm_inactivity_minutes = 0 + +[[inference.providers]] +id = "builtin" +kind = "builtin" +label = "Built-in (Thuki)" +model = "" + +[[inference.providers]] +id = "ollama" +kind = "ollama" +label = "Ollama" +base_url = "http://127.0.0.1:11434" +model = "" + +[[inference.providers]] +id = "openai" +kind = "openai" +label = "LM Studio" +base_url = "http://127.0.0.1:1234" +model = "" +vision = false +"#; + // ─── ALLOWED_FIELDS / ALLOWED_SECTIONS ────────────────────────────────────── #[test] @@ -854,13 +885,163 @@ fn write_provider_field_rejects_unknown_field() { let dir = tempdir(); let path = dir.join("config.toml"); std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); - let err = write_provider_field_to_disk(&path, "ollama", "label", "x").unwrap_err(); + let err = write_provider_field_to_disk(&path, "ollama", "id", "x").unwrap_err(); match err { - ConfigError::UnknownField { key, .. } => assert_eq!(key, "label"), + ConfigError::UnknownField { key, .. } => assert_eq!(key, "id"), other => panic!("expected UnknownField, got {other:?}"), } } +#[test] +fn write_provider_field_patches_label() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + + let resolved = write_provider_field_to_disk(&path, "ollama", "label", " My Ollama ").unwrap(); + let ollama = resolved + .inference + .providers + .iter() + .find(|p| p.id == "ollama") + .unwrap(); + assert_eq!(ollama.label, "My Ollama"); +} + +#[test] +fn write_provider_field_heals_empty_openai_label_to_default() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, OPENAI_PROVIDERS_CONFIG).unwrap(); + + let resolved = write_provider_field_to_disk(&path, "openai", "label", " ").unwrap(); + let openai = resolved + .inference + .providers + .iter() + .find(|p| p.id == "openai") + .unwrap(); + assert_eq!(openai.label, crate::config::defaults::DEFAULT_OPENAI_LABEL); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains(crate::config::defaults::DEFAULT_OPENAI_LABEL)); +} + +#[test] +fn validate_provider_value_heals_only_empty_openai_labels() { + // Non-empty labels trim for every kind. + let item = validate_provider_value("openai", "label", " Jan ").unwrap(); + assert_eq!(item.as_str(), Some("Jan")); + // A trimmed-empty label on a non-openai kind is not healed. + let item = validate_provider_value("ollama", "label", " ").unwrap(); + assert_eq!(item.as_str(), Some("")); + // A trimmed-empty label on the openai kind heals to the default. + let item = validate_provider_value("openai", "label", "").unwrap(); + assert_eq!( + item.as_str(), + Some(crate::config::defaults::DEFAULT_OPENAI_LABEL) + ); +} + +#[test] +fn write_provider_field_patches_vision_as_boolean() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, OPENAI_PROVIDERS_CONFIG).unwrap(); + + let resolved = write_provider_field_to_disk(&path, "openai", "vision", "true").unwrap(); + let openai = resolved + .inference + .providers + .iter() + .find(|p| p.id == "openai") + .unwrap(); + assert!(openai.vision); + + // Stored as a real TOML boolean, not the string "true". + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains("vision = true")); + + let resolved = write_provider_field_to_disk(&path, "openai", "vision", "false").unwrap(); + let openai = resolved + .inference + .providers + .iter() + .find(|p| p.id == "openai") + .unwrap(); + assert!(!openai.vision); +} + +#[test] +fn write_provider_field_rejects_malformed_vision_value() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, OPENAI_PROVIDERS_CONFIG).unwrap(); + let err = write_provider_field_to_disk(&path, "openai", "vision", "yes").unwrap_err(); + match err { + ConfigError::TypeMismatch { key, .. } => assert_eq!(key, "vision"), + other => panic!("expected TypeMismatch, got {other:?}"), + } +} + +#[test] +fn write_provider_field_rejects_non_http_base_url() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + let err = write_provider_field_to_disk(&path, "ollama", "base_url", "ftp://x").unwrap_err(); + match err { + ConfigError::TypeMismatch { key, message, .. } => { + assert_eq!(key, "base_url"); + assert!(message.contains("http://")); + } + other => panic!("expected TypeMismatch, got {other:?}"), + } +} + +#[test] +fn write_provider_field_rejects_builtin_base_url() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + let err = + write_provider_field_to_disk(&path, "builtin", "base_url", "http://10.0.0.1").unwrap_err(); + match err { + ConfigError::TypeMismatch { message, .. } => { + assert!(message.contains("built-in")); + } + other => panic!("expected TypeMismatch, got {other:?}"), + } +} + +#[test] +fn validate_provider_value_rejects_field_outside_allowlist() { + // The wrapper gates the field name first, so this arm is only reachable + // by calling the helper directly; cover it here. + let err = validate_provider_value("ollama", "kind", "x").unwrap_err(); + match err { + ConfigError::UnknownField { key, .. } => assert_eq!(key, "kind"), + other => panic!("expected UnknownField, got {other:?}"), + } +} + +// ─── is_http_url ───────────────────────────────────────────────────────────── + +#[test] +fn is_http_url_accepts_http_and_https_with_surrounding_whitespace() { + assert!(is_http_url("http://127.0.0.1:1234")); + assert!(is_http_url("https://example.com/v1")); + assert!(is_http_url(" http://host ")); +} + +#[test] +fn is_http_url_rejects_other_schemes_and_empty() { + assert!(!is_http_url("")); + assert!(!is_http_url(" ")); + assert!(!is_http_url("ftp://host")); + assert!(!is_http_url("127.0.0.1:1234")); +} + #[test] fn write_provider_field_rejects_unknown_provider() { let dir = tempdir(); @@ -980,6 +1161,202 @@ fn set_active_provider_propagates_io_error_when_parent_dir_is_readonly() { matches!(err, ConfigError::IoError { .. }); } +// ─── add_openai_provider_to_disk ───────────────────────────────────────────── + +#[test] +fn add_openai_appends_provider_with_custom_label() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + + let resolved = + add_openai_provider_to_disk(&path, "LM Studio", "http://127.0.0.1:1234").unwrap(); + let openai = resolved + .inference + .providers + .iter() + .find(|p| p.kind == "openai") + .unwrap(); + assert_eq!(openai.id, "openai"); + assert_eq!(openai.label, "LM Studio"); + assert_eq!(openai.base_url, "http://127.0.0.1:1234"); + assert_eq!(openai.model, ""); + assert!(!openai.vision); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains("kind = \"openai\"")); + assert!(on_disk.contains("http://127.0.0.1:1234")); +} + +#[test] +fn add_openai_defaults_empty_label() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + + let resolved = add_openai_provider_to_disk(&path, " ", "https://10.0.0.5:1234").unwrap(); + let openai = resolved + .inference + .providers + .iter() + .find(|p| p.kind == "openai") + .unwrap(); + assert_eq!(openai.label, "OpenAI-compatible"); +} + +#[test] +fn add_openai_rejects_non_http_base_url() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + let err = add_openai_provider_to_disk(&path, "x", "localhost:1234").unwrap_err(); + match err { + ConfigError::TypeMismatch { key, .. } => assert_eq!(key, "base_url"), + other => panic!("expected TypeMismatch, got {other:?}"), + } +} + +#[test] +fn add_openai_rejects_second_openai_provider() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, OPENAI_PROVIDERS_CONFIG).unwrap(); + let err = add_openai_provider_to_disk(&path, "Another", "http://127.0.0.1:9999").unwrap_err(); + match err { + ConfigError::TypeMismatch { message, .. } => { + assert!(message.contains("already exists")); + } + other => panic!("expected TypeMismatch, got {other:?}"), + } +} + +#[test] +fn add_openai_errors_when_no_providers_array() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + let err = add_openai_provider_to_disk(&path, "x", "http://127.0.0.1:1234").unwrap_err(); + match err { + ConfigError::UnknownSection { section } => assert_eq!(section, "inference.providers"), + other => panic!("expected UnknownSection, got {other:?}"), + } +} + +#[test] +fn add_openai_propagates_read_error_for_missing_file() { + let dir = tempdir(); + let path = dir.join("missing.toml"); + let err = add_openai_provider_to_disk(&path, "x", "http://127.0.0.1:1234").unwrap_err(); + matches!(err, ConfigError::IoError { .. }); +} + +#[cfg(unix)] +#[test] +fn add_openai_propagates_io_error_when_parent_dir_is_readonly() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + + let mut perms = std::fs::metadata(&dir).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&dir, perms.clone()).unwrap(); + + let err = add_openai_provider_to_disk(&path, "x", "http://127.0.0.1:1234").unwrap_err(); + + let mut restore = perms; + restore.set_mode(0o700); + std::fs::set_permissions(&dir, restore).unwrap(); + + matches!(err, ConfigError::IoError { .. }); +} + +// ─── remove_openai_provider_from_disk ──────────────────────────────────────── + +#[test] +fn remove_openai_deletes_entry_and_keeps_active_pointer() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, OPENAI_PROVIDERS_CONFIG).unwrap(); + + let resolved = remove_openai_provider_from_disk(&path).unwrap(); + assert!(!resolved + .inference + .providers + .iter() + .any(|p| p.kind == "openai")); + // Active was "ollama" and stays "ollama". + assert_eq!(resolved.inference.active_provider, "ollama"); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(!on_disk.contains("kind = \"openai\"")); +} + +#[test] +fn remove_openai_falls_back_to_builtin_when_it_was_active() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, OPENAI_PROVIDERS_CONFIG).unwrap(); + write_active_provider_to_disk(&path, "openai").unwrap(); + + let resolved = remove_openai_provider_from_disk(&path).unwrap(); + assert_eq!(resolved.inference.active_provider, "builtin"); + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert!(on_disk.contains("active_provider = \"builtin\"")); + + // The command re-mirrors the in-memory active model through this exact + // decision helper: builtin has no model yet, so the mirror clears. + assert_eq!( + crate::models::should_refresh_active_model("builtin", &resolved), + Some(None) + ); +} + +#[test] +fn remove_openai_errors_when_no_openai_provider() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, PROVIDERS_CONFIG).unwrap(); + let err = remove_openai_provider_from_disk(&path).unwrap_err(); + match err { + ConfigError::UnknownField { key, .. } => assert_eq!(key, "openai"), + other => panic!("expected UnknownField, got {other:?}"), + } +} + +#[test] +fn remove_openai_errors_when_no_providers_array() { + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, SAMPLE_CONFIG).unwrap(); + let err = remove_openai_provider_from_disk(&path).unwrap_err(); + match err { + ConfigError::UnknownSection { section } => assert_eq!(section, "inference.providers"), + other => panic!("expected UnknownSection, got {other:?}"), + } +} + +#[cfg(unix)] +#[test] +fn remove_openai_propagates_io_error_when_parent_dir_is_readonly() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir(); + let path = dir.join("config.toml"); + std::fs::write(&path, OPENAI_PROVIDERS_CONFIG).unwrap(); + + let mut perms = std::fs::metadata(&dir).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&dir, perms.clone()).unwrap(); + + let err = remove_openai_provider_from_disk(&path).unwrap_err(); + + let mut restore = perms; + restore.set_mode(0o700); + std::fs::set_permissions(&dir, restore).unwrap(); + + matches!(err, ConfigError::IoError { .. }); +} + #[cfg(unix)] #[test] fn write_provider_field_propagates_io_error_when_parent_dir_is_readonly() { diff --git a/src/hooks/__tests__/useDownloadModel.test.tsx b/src/hooks/__tests__/useDownloadModel.test.tsx index fc5aad26..34f0b9a0 100644 --- a/src/hooks/__tests__/useDownloadModel.test.tsx +++ b/src/hooks/__tests__/useDownloadModel.test.tsx @@ -262,13 +262,56 @@ describe('useDownloadModel', () => { }); }); - it('ignores retry before any start recorded a tier', async () => { + it('ignores retry before any start recorded a download', async () => { const { result } = renderHook(() => useDownloadModel()); await act(() => result.current.retry()); expect(result.current.state).toEqual({ phase: 'idle' }); expect(invoke).not.toHaveBeenCalled(); }); + it('starts a pasted-repo download through download_repo_model', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.startRepo('owner/repo', 'w.gguf')); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenCalledWith('download_repo_model', { + repo: 'owner/repo', + file: 'w.gguf', + onEvent: expect.anything(), + }); + act(() => channel().simulateMessage({ type: 'AllDone' })); + expect(result.current.state).toEqual({ phase: 'ready' }); + }); + + it('retries the last repo download after a failure', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.startRepo('owner/repo', 'w.gguf')); + act(() => + channel().simulateMessage({ + type: 'Failed', + data: { kind: 'http', message: 'HTTP 500' }, + }), + ); + + await act(() => result.current.retry()); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenLastCalledWith('download_repo_model', { + repo: 'owner/repo', + file: 'w.gguf', + onEvent: expect.anything(), + }); + }); + + it('maps a rejected download_repo_model invoke to failed/other', async () => { + invoke.mockRejectedValueOnce('invalid Hugging Face repo id'); + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.startRepo('bad', 'w.gguf')); + expect(result.current.state).toEqual({ + phase: 'failed', + kind: 'other', + message: 'invalid Hugging Face repo id', + }); + }); + it('resumes through the same start call', async () => { const { result } = renderHook(() => useDownloadModel()); act(() => result.current.enterResumePending()); diff --git a/src/hooks/useDownloadModel.ts b/src/hooks/useDownloadModel.ts index 4f093e53..4ac09787 100644 --- a/src/hooks/useDownloadModel.ts +++ b/src/hooks/useDownloadModel.ts @@ -89,6 +89,11 @@ export interface UseDownloadModel { cancelConfirm: () => void; /** confirming -> downloading; invokes `download_starter` with a channel. */ start: (tier: StarterTier) => Promise; + /** + * idle -> downloading for a pasted-repo model; invokes `download_repo_model` + * with a channel. Same event stream and terminal states as `start`. + */ + startRepo: (repo: string, file: string) => Promise; /** * Invokes `cancel_model_download`. The state flips back to idle when the * backend's Cancelled event lands; the partial is KEPT, so the caller @@ -97,7 +102,8 @@ export interface UseDownloadModel { cancel: () => Promise; /** * failed -> downloading. A checksum failure already deleted the partial - * on the backend, so retrying is just starting the same tier again. + * on the backend, so retrying is just starting the same download (starter + * tier or pasted repo, whichever ran last) again. */ retry: () => Promise; /** resume_pending -> downloading; the backend resumes via Range. */ @@ -128,7 +134,8 @@ export function useDownloadModel( const samplesRef = useRef([]); const startedCountRef = useRef(0); - const lastTierRef = useRef(null); + /** Replays the most recent start (tier or repo) for `retry`. */ + const lastStartRef = useRef<(() => Promise) | null>(null); const handleEvent = useCallback( (event: DownloadEvent) => { @@ -233,9 +240,10 @@ export function useDownloadModel( setState({ phase: 'idle' }); }, []); - const start = useCallback( - async (tier: StarterTier) => { - lastTierRef.current = tier; + /** Shared start path: resets per-run trackers, wires the event channel, + * and invokes the given download command. */ + const run = useCallback( + async (command: string, args: Record) => { startedCountRef.current = 0; samplesRef.current = []; setProgress(null); @@ -244,7 +252,7 @@ export function useDownloadModel( const channel = new Channel(); channel.onmessage = handleEvent; try { - await invoke('download_starter', { tier, onEvent: channel }); + await invoke(command, { ...args, onEvent: channel }); } catch (err) { setState({ phase: 'failed', kind: 'other', message: String(err) }); } @@ -252,15 +260,33 @@ export function useDownloadModel( [handleEvent], ); + const start = useCallback( + async (tier: StarterTier) => { + const replay = () => run('download_starter', { tier }); + lastStartRef.current = replay; + await replay(); + }, + [run], + ); + + const startRepo = useCallback( + async (repo: string, file: string) => { + const replay = () => run('download_repo_model', { repo, file }); + lastStartRef.current = replay; + await replay(); + }, + [run], + ); + const cancel = useCallback(async () => { await invoke('cancel_model_download'); }, []); const retry = useCallback(async () => { - const tier = lastTierRef.current; - if (tier === null) return; - await start(tier); - }, [start]); + const replay = lastStartRef.current; + if (replay === null) return; + await replay(); + }, []); const discard = useCallback(async (sha256: string) => { try { @@ -283,6 +309,7 @@ export function useDownloadModel( beginConfirm, cancelConfirm, start, + startRepo, cancel, retry, resume: start, diff --git a/src/settings/configHelpers.ts b/src/settings/configHelpers.ts index 75578628..bd0dc2e7 100644 --- a/src/settings/configHelpers.ts +++ b/src/settings/configHelpers.ts @@ -18,6 +18,16 @@ const HELPERS = { 'The address where Thuki reaches your Ollama server. The default works if you run Ollama on this Mac with its standard port. Point it at another machine to use Ollama running elsewhere (one server at a time).', keep_warm: 'When on, Thuki tells Ollama to keep the active model loaded in GPU memory between conversations, saving the cold-load wait on every open. Set "Release after" to −1 to keep it warm indefinitely, or pick a timeout in minutes so GPU memory is reclaimed when you stop using Thuki for a while.', + builtin_model: + 'The downloaded model Thuki\'s built-in engine runs. Pick from the models you have downloaded, or use "Download a model" below to grab a curated starter or any GGUF file from a Hugging Face repo.', + idle_unload_minutes: + 'How many minutes of inactivity before Thuki stops its built-in engine to free memory. 0 (the default) keeps the model loaded so the first token of your next message stays instant. A positive value frees memory after that many idle minutes, at the cost of a cold reload on the next message.', + openai_base_url: + 'The address of your OpenAI-compatible server (LM Studio, Jan, llama-server, and similar all expose one). Thuki calls its /v1 endpoints for chat and model listing. Must start with http:// or https://.', + openai_api_key: + "The API key sent as a Bearer token to your OpenAI-compatible server, stored only in the macOS Keychain. It is never written to config.toml and never shown again after saving; leave it empty for local servers that don't require one.", + openai_vision: + 'Whether the selected model accepts image inputs. OpenAI-compatible servers expose no capability probe, so you declare it yourself. Turn it on only if the model truly supports images; otherwise requests with attachments will fail.', num_ctx: "The size of the context window sent to Ollama with every request, in tokens. This value must match between warmup and chat so Ollama can reuse the same runner and its cached key-value prefix for the system prompt. Raise to fit longer conversations without the model forgetting early messages; lower to reduce GPU memory use. Ollama caps the effective value at the model's trained maximum, so anything beyond that is silently clamped, not used. Valid range: 2048–1048576. The default (16384) comfortably fits the system prompt plus several long turns.", }, diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index 24bc74b6..bc99c933 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -1,10 +1,10 @@ /** * AI tab. * - * Holds the local Ollama endpoint, keep-warm controls, and the custom system - * prompt. The active model picker lives in the main app overlay (see - * ModelPickerPanel) since model selection is runtime UI state owned by - * ActiveModelState in the backend, not a TOML-persisted field. The + * Holds the Providers panel (built-in engine, Ollama, and an optional + * OpenAI-compatible server, with the active one selectable), the per-kind + * memory controls (Keep Warm for Ollama, Idle Unload for the built-in + * engine), the context window slider, and the custom system prompt. The * Window/Quote knobs live in the Display tab. */ @@ -14,6 +14,11 @@ import { listen } from '@tauri-apps/api/event'; import { Section, SettingRow, Dropdown, Textarea, Toggle } from '../components'; import { SaveField } from '../components/SaveField'; +import { + AddOpenAiProvider, + BuiltinProviderCard, + OpenAiProviderCard, +} from './ProviderCards'; import { useDebouncedSave } from '../hooks/useDebouncedSave'; import { useModelSelection } from '../../hooks/useModelSelection'; import { isNonLocalUrl } from '../../utils/isNonLocalUrl'; @@ -22,6 +27,7 @@ import { DrawCheckIcon } from '../../components/DrawCheckIcon'; import { Tooltip } from '../../components/Tooltip'; import styles from '../../styles/settings.module.css'; import type { RawAppConfig } from '../types'; +import type { EngineStatus } from '../../types/starter'; interface ModelTabProps { config: RawAppConfig; @@ -89,6 +95,19 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { const [ejecting, setEjecting] = useState(false); const [loadedModel, setLoadedModel] = useState(null); + // Providers panel: who is active and of which kind, derived from the + // config snapshot so a resync always reflects disk. + const providers = config.inference.providers; + const activeId = config.inference.active_provider; + const activeKind = providers.find((p) => p.id === activeId)?.kind ?? 'ollama'; + const builtinProvider = providers.find((p) => p.kind === 'builtin'); + const openaiProvider = providers.find((p) => p.kind === 'openai'); + + // Latest engine lifecycle snapshot; drives the built-in residency line and + // the context slider's non-blocking "Applying" hint. + const [engineState, setEngineState] = + useState('stopped'); + // Context window: committed value drives the debounced save; local slider // pos updates live on drag without committing on every pixel. const [numCtx, setNumCtx] = useState(config.inference.num_ctx); @@ -148,6 +167,18 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { }; }, []); + useEffect(() => { + let unlisten: (() => void) | null = null; + void listen('engine:status', (e) => { + setEngineState(e.payload.state); + }).then((fn) => { + unlisten = fn; + }); + return () => { + unlisten?.(); + }; + }, []); + const { resetTo: resetMin } = useDebouncedSave( 'inference', 'keep_warm_inactivity_minutes', @@ -162,6 +193,21 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { { onSaved }, ); + // Built-in engine idle-unload minutes (replaces keep-warm when the + // built-in provider is active). Same raw-string editing pattern as the + // keep-warm minutes input above. + const [idleMin, setIdleMin] = useState(config.inference.idle_unload_minutes); + const [rawIdleMin, setRawIdleMin] = useState( + String(config.inference.idle_unload_minutes), + ); + const idleMinFocusedRef = useRef(false); + const { resetTo: resetIdleMin } = useDebouncedSave( + 'inference', + 'idle_unload_minutes', + idleMin, + { onSaved }, + ); + const prevTokenRef = useRef(resyncToken); if (prevTokenRef.current !== resyncToken) { @@ -171,6 +217,11 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { setRawMin(String(config.inference.keep_warm_inactivity_minutes)); resetMin(config.inference.keep_warm_inactivity_minutes); } + if (!idleMinFocusedRef.current) { + setIdleMin(config.inference.idle_unload_minutes); + setRawIdleMin(String(config.inference.idle_unload_minutes)); + resetIdleMin(config.inference.idle_unload_minutes); + } const nextCtx = config.inference.num_ctx; setNumCtx(nextCtx); setCtxPos(ctxToPos(nextCtx)); @@ -208,6 +259,30 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { }); } + function selectProvider(id: string) { + // Radios only fire onChange when the selection actually changes, so no + // same-provider guard is needed here. + void invoke('set_active_provider', { providerId: id }) + .then((cfg) => onSaved(cfg)) + .catch(() => { + // Switching failed (e.g. config write error): the radio re-seeds + // from config on the next render. + }); + } + + function handleEngineEject() { + void invoke('evict_model').catch(() => { + // The engine:status event stream is the source of truth; a failed + // eviction simply leaves the residency line unchanged. + }); + } + + function providerCardClass(active: boolean): string { + return active + ? `${styles.providerCard} ${styles.providerCardActive}` + : styles.providerCard; + } + const modelValue = activeModel && availableModels.includes(activeModel) ? activeModel @@ -219,160 +294,275 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { return ( <>
-
- Built-in (Thuki) - - Available in an upcoming version - +
+ +
-
Ollama
- - { - ollamaUrlFocusedRef.current = true; - }} - onChange={(e) => setOllamaUrl(e.target.value)} - onBlur={() => { - ollamaUrlFocusedRef.current = false; - commitOllamaUrl(); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); - }} - /> - - {isNonLocalUrl(ollamaUrl) && ( -

- This points Thuki at a non-local Ollama server. You are responsible - for securing it: prefer a VPN/Tailscale or SSH tunnel over exposing - the port directly. -

- )} - - {availableModels.length > 0 ? ( - void setActiveModel(m)} - ariaLabel="Active Ollama model" + -
- -
- {/* Row 1: label + [?] on left | Release after [N] min on right */} -
-
- - Keep active model in VRAM - - - - -
-
- Release after + Ollama + + { - minFocusedRef.current = true; - }} - onChange={(e) => { - const n = parseInt(e.target.value, 10); - if (Number.isNaN(n)) { - setRawMin(e.target.value); - } else { - const clamped = Math.max(-1, Math.min(1440, n)); - setRawMin(String(clamped)); - setInactivityMin(clamped); - } + ollamaUrlFocusedRef.current = true; }} + onChange={(e) => setOllamaUrl(e.target.value)} onBlur={() => { - minFocusedRef.current = false; - if (Number.isNaN(parseInt(rawMin, 10))) { - setRawMin('0'); - setInactivityMin(0); - } + ollamaUrlFocusedRef.current = false; + commitOllamaUrl(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} /> - min -
-
- - {/* Row 2: slug status on left | Unload now on right */} -
-
- {loadedModel !== null ? ( -
-
+ + {isNonLocalUrl(ollamaUrl) && ( +

+ This points Thuki at a non-local Ollama server. You are + responsible for securing it: prefer a VPN/Tailscale or SSH tunnel + over exposing the port directly. +

+ )} + + {availableModels.length > 0 ? ( + void setActiveModel(m)} + ariaLabel="Active Ollama model" + /> ) : ( - No model loaded + No models installed )} -
+ +
- -
+ + +
+ ) : ( + + )} + {activeKind === 'builtin' ? ( +
+ +
+ { + idleMinFocusedRef.current = true; + }} + onChange={(e) => { + const n = parseInt(e.target.value, 10); + if (Number.isNaN(n)) { + setRawIdleMin(e.target.value); + } else { + const clamped = Math.max(0, Math.min(1440, n)); + setRawIdleMin(String(clamped)); + setIdleMin(clamped); + } + }} + onBlur={() => { + idleMinFocusedRef.current = false; + if (Number.isNaN(parseInt(rawIdleMin, 10))) { + setRawIdleMin('0'); + setIdleMin(0); + } + }} + /> + min +
+
+
+ + Engine: {engineState} + + +
+
+ ) : null} + + {activeKind === 'ollama' ? ( +
+ {/* Row 1: label + [?] on left | Release after [N] min on right */} +
+
+ + Keep active model in VRAM + + + + +
+
+ + Release after + + { + minFocusedRef.current = true; + }} + onChange={(e) => { + const n = parseInt(e.target.value, 10); + if (Number.isNaN(n)) { + setRawMin(e.target.value); + } else { + const clamped = Math.max(-1, Math.min(1440, n)); + setRawMin(String(clamped)); + setInactivityMin(clamped); + } + }} + onBlur={() => { + minFocusedRef.current = false; + if (Number.isNaN(parseInt(rawMin, 10))) { + setRawMin('0'); + setInactivityMin(0); + } + }} + /> + min +
+
+ + {/* Row 2: slug status on left | Unload now on right */} +
+
+ {loadedModel !== null ? ( +
+
+ ) : ( + No model loaded + )} +
+ + +
+
+ ) : null} +
{/* Label row: "Context window" left + editable token chip right */} @@ -450,6 +640,14 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { ))}
+ {activeKind === 'builtin' && + (engineState === 'starting' || engineState === 'stopping') ? ( +
+ Applying… the engine restarts with the new context on your next + message. +
+ ) : null} +
~{ctxTurns.toLocaleString()} turns of context {' · '} diff --git a/src/settings/tabs/ProviderCards.test.tsx b/src/settings/tabs/ProviderCards.test.tsx new file mode 100644 index 00000000..10dc2dfb --- /dev/null +++ b/src/settings/tabs/ProviderCards.test.tsx @@ -0,0 +1,1175 @@ +/** + * Unit tests for the Providers panel card bodies. + * + * - `BuiltinProviderCard`: installed-model picker, the shared download kit + * (starter picker, confirm card, paste-a-repo lookup), and the post-download + * config lift. + * - `OpenAiProviderCard`: editable label/base URL/model, write-only API key, + * vision toggle, and removal with confirm. + * - `AddOpenAiProvider`: the inline add-a-server affordance. + * + * `invoke` and `Channel` come from the global Tauri mocks; download events + * are driven by simulating messages on the captured channel. + */ + +import { useState } from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { invoke } from '@tauri-apps/api/core'; + +import { + AddOpenAiProvider, + BuiltinProviderCard, + OpenAiProviderCard, +} from './ProviderCards'; +import type { RawAppConfig, RawProvider } from '../types'; +import type { InstalledModel, StarterOption } from '../../types/starter'; + +const invokeMock = invoke as unknown as ReturnType; + +const BASE_CONFIG: RawAppConfig = { + inference: { + active_provider: 'builtin', + keep_warm_inactivity_minutes: 0, + idle_unload_minutes: 0, + num_ctx: 16384, + providers: [ + { + id: 'builtin', + kind: 'builtin', + label: 'Built-in (Thuki)', + base_url: '', + model: '', + vision: false, + }, + { + id: 'ollama', + kind: 'ollama', + label: 'Ollama', + base_url: 'http://127.0.0.1:11434', + model: '', + vision: false, + }, + ], + }, + prompt: { system: 'hello' }, + window: { + overlay_width: 600, + max_chat_height: 648, + max_images: 3, + text_base_px: 15, + text_line_height: 1.5, + text_letter_spacing_px: 0, + text_font_weight: 500, + }, + quote: { + max_display_lines: 4, + max_display_chars: 300, + max_context_length: 4096, + }, + behavior: { auto_replace: false, auto_close: false }, + search: { + searxng_url: 'http://127.0.0.1:25017', + reader_url: 'http://127.0.0.1:25018', + max_iterations: 3, + top_k_urls: 10, + searxng_max_results: 10, + search_timeout_s: 20, + reader_per_url_timeout_s: 10, + reader_batch_timeout_s: 30, + judge_timeout_s: 30, + router_timeout_s: 45, + }, + debug: { trace_enabled: false }, +}; + +/** Distinct snapshot so onSaved assertions cannot pass by referential luck. */ +const NEW_CONFIG: RawAppConfig = { + ...BASE_CONFIG, + prompt: { system: 'updated' }, +}; + +function makeConfig(builtinModel: string): RawAppConfig { + return { + ...BASE_CONFIG, + inference: { + ...BASE_CONFIG.inference, + providers: [ + { ...BASE_CONFIG.inference.providers[0], model: builtinModel }, + BASE_CONFIG.inference.providers[1], + ], + }, + }; +} + +const INSTALLED: InstalledModel[] = [ + { id: 'org/gemma:gemma.gguf', display_name: 'gemma', quant: 'Q4_K_M' }, + { id: 'org/qwen:qwen.gguf', display_name: 'qwen', quant: '' }, +]; + +const STARTER_OPTION: StarterOption = { + starter: { + tier: 'balanced', + display_name: 'Gemma 4', + repo: 'org/gemma', + revision: 'abc123', + file_name: 'gemma.gguf', + sha256: 'sha-balanced', + size_bytes: 5_000_000_000, + quant: 'Q4_K_M', + vision: false, + thinking: false, + mmproj_file: null, + mmproj_sha256: null, + mmproj_bytes: 0, + est_runtime_gb: 6, + license_note: '', + }, + fit: 'fits', + installed: false, + partial_bytes: null, +}; + +const OPENAI_PROVIDER: RawProvider = { + id: 'openai', + kind: 'openai', + label: 'LM Studio', + base_url: 'http://127.0.0.1:1234', + model: '', + vision: false, +}; + +/** BASE_CONFIG with the given OpenAI-compatible provider row appended. */ +function configWith(provider: RawProvider): RawAppConfig { + return { + ...BASE_CONFIG, + inference: { + ...BASE_CONFIG.inference, + providers: [...BASE_CONFIG.inference.providers, provider], + }, + }; +} + +/** + * Wraps the card the way ModelTab does: `onSaved` lifts the returned config + * and the card re-renders with the updated provider row. + */ +function StatefulOpenAiCard() { + const [provider, setProvider] = useState(OPENAI_PROVIDER); + return ( + { + const next = cfg.inference.providers.find((p) => p.id === 'openai'); + if (next) setProvider(next); + }} + /> + ); +} + +type MockChannel = { simulateMessage: (msg: unknown) => void }; + +/** Marks a command response as a rejection in `mockCommands`. */ +class Reject { + constructor(public readonly value: unknown) {} +} + +let lastChannel: MockChannel | null = null; + +/** + * Routes `invoke` by command name. Values: `Reject` throws its payload, + * functions are called with the invoke args (for stateful sequences), and + * anything else resolves as-is. Channels passed via `onEvent` are captured. + */ +function mockCommands(responses: Record) { + invokeMock.mockImplementation( + async (cmd: string, args?: Record) => { + if (args && 'onEvent' in args) { + lastChannel = args.onEvent as unknown as MockChannel; + } + if (Object.prototype.hasOwnProperty.call(responses, cmd)) { + const v = responses[cmd]; + if (v instanceof Reject) throw v.value; + if (typeof v === 'function') { + return (v as (a?: Record) => unknown)(args); + } + return v; + } + return undefined; + }, + ); +} + +/** Default backend for the builtin card: two installed models, one starter. */ +function builtinResponses(overrides: Record = {}) { + return { + list_installed_models: INSTALLED, + get_starter_options: [STARTER_OPTION], + get_models_dir_free_bytes: 50_000_000_000, + get_config: NEW_CONFIG, + ...overrides, + }; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +beforeEach(() => { + invokeMock.mockReset(); + lastChannel = null; +}); + +// ─── BuiltinProviderCard ───────────────────────────────────────────────────── + +describe('BuiltinProviderCard', () => { + async function renderCard( + builtinModel = '', + onSaved: (next: RawAppConfig) => void = () => {}, + ) { + const view = render( + , + ); + await flush(); + return view; + } + + it('renders installed models with a Choose placeholder when none is selected', async () => { + mockCommands(builtinResponses()); + await renderCard(''); + const select = screen.getByRole('combobox', { + name: 'Built-in model', + }) as HTMLSelectElement; + expect(select.value).toBe(''); + expect(screen.getByText('Choose a model')).toBeInTheDocument(); + expect(screen.getByText('gemma · Q4_K_M')).toBeInTheDocument(); + expect(screen.getByText('qwen')).toBeInTheDocument(); + }); + + it('selects the persisted builtin model and omits the placeholder', async () => { + mockCommands(builtinResponses()); + await renderCard('org/gemma:gemma.gguf'); + const select = screen.getByRole('combobox', { + name: 'Built-in model', + }) as HTMLSelectElement; + expect(select.value).toBe('org/gemma:gemma.gguf'); + expect(screen.queryByText('Choose a model')).not.toBeInTheDocument(); + }); + + it('committing a model invokes update_provider_field and lifts the config', async () => { + mockCommands(builtinResponses({ update_provider_field: NEW_CONFIG })); + const onSaved = vi.fn(); + await renderCard('', onSaved); + fireEvent.change(screen.getByRole('combobox', { name: 'Built-in model' }), { + target: { value: 'org/qwen:qwen.gguf' }, + }); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'builtin', + field: 'model', + value: 'org/qwen:qwen.gguf', + }); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + }); + + it('swallows an update_provider_field failure on model commit', async () => { + mockCommands( + builtinResponses({ + update_provider_field: new Reject(new Error('write failed')), + }), + ); + const onSaved = vi.fn(); + await renderCard('', onSaved); + fireEvent.change(screen.getByRole('combobox', { name: 'Built-in model' }), { + target: { value: 'org/qwen:qwen.gguf' }, + }); + await flush(); + expect(onSaved).not.toHaveBeenCalled(); + expect( + screen.getByRole('combobox', { name: 'Built-in model' }), + ).toBeInTheDocument(); + }); + + it('shows the no-models hint when the manifest is empty', async () => { + mockCommands(builtinResponses({ list_installed_models: [] })); + await renderCard(); + expect(screen.getByText('No models downloaded yet')).toBeInTheDocument(); + }); + + it('treats a non-array list_installed_models payload as empty', async () => { + mockCommands(builtinResponses({ list_installed_models: null })); + await renderCard(); + expect(screen.getByText('No models downloaded yet')).toBeInTheDocument(); + }); + + it('falls back to empty state when the manifest and disk probes reject', async () => { + mockCommands( + builtinResponses({ + list_installed_models: new Reject(new Error('manifest unreadable')), + get_models_dir_free_bytes: new Reject(new Error('statfs failed')), + }), + ); + await renderCard(); + expect(screen.getByText('No models downloaded yet')).toBeInTheDocument(); + }); + + it('keeps the download kit hidden until starter options resolve', async () => { + mockCommands( + builtinResponses({ get_starter_options: new Promise(() => {}) }), + ); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + expect( + screen.queryByRole('button', { name: 'Look up' }), + ).not.toBeInTheDocument(); + }); + + it('toggles the download kit open and closed', async () => { + mockCommands(builtinResponses()); + await renderCard(); + const trigger = screen.getByRole('button', { name: 'Download a model' }); + fireEvent.click(trigger); + expect(screen.getByText('Gemma 4')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Look up' })).toBeInTheDocument(); + fireEvent.click(trigger); + expect(screen.queryByText('Gemma 4')).not.toBeInTheDocument(); + }); + + it('walks the confirm flow and lifts the config when the download finishes', async () => { + mockCommands(builtinResponses()); + const onSaved = vi.fn(); + await renderCard('', onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + // Row-level Download opens the confirm card. + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(screen.getByText('5.0 GB download.')).toBeInTheDocument(); + expect(screen.getByText('50.0 GB free on this disk.')).toBeInTheDocument(); + // Two Download buttons now: the picker row's and the confirm card's. + const confirmBtn = screen.getAllByRole('button', { name: 'Download' })[1]; + fireEvent.click(confirmBtn); + await flush(); + expect(invokeMock).toHaveBeenCalledWith( + 'download_starter', + expect.objectContaining({ tier: 'balanced' }), + ); + act(() => { + lastChannel?.simulateMessage({ type: 'AllDone' }); + }); + await waitFor(() => expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG)); + }); + + it('leaves the lift to the focus resync when get_config fails post-download', async () => { + mockCommands( + builtinResponses({ get_config: new Reject(new Error('read failed')) }), + ); + const onSaved = vi.fn(); + await renderCard('', onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Download' })[1]); + await flush(); + act(() => { + lastChannel?.simulateMessage({ type: 'AllDone' }); + }); + await flush(); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it('hides the free-disk line when the free-bytes probe returns a non-number', async () => { + mockCommands(builtinResponses({ get_models_dir_free_bytes: null })); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(screen.getByText('5.0 GB download.')).toBeInTheDocument(); + expect(screen.queryByText(/free on this disk/)).not.toBeInTheDocument(); + // Cancel returns to the plain picker. + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByText('5.0 GB download.')).not.toBeInTheDocument(); + }); + + it('cancels an in-flight download and retries after a failure', async () => { + mockCommands(builtinResponses()); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Download' })[1]); + await flush(); + expect(screen.getByText('Downloading model')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('cancel_model_download'); + act(() => { + lastChannel?.simulateMessage({ + type: 'Failed', + data: { kind: 'other', message: 'socket closed' }, + }); + }); + expect(screen.getByText('socket closed')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await flush(); + const starts = invokeMock.mock.calls.filter( + (c: unknown[]) => c[0] === 'download_starter', + ); + expect(starts).toHaveLength(2); + }); + + it('enters resume_pending for an interrupted partial and resumes from it', async () => { + mockCommands( + builtinResponses({ + get_starter_options: [ + { ...STARTER_OPTION, partial_bytes: 1_000_000_000 }, + ], + }), + ); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + await flush(); + fireEvent.click(screen.getByRole('button', { name: /Resume download/ })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith( + 'download_starter', + expect.objectContaining({ tier: 'balanced' }), + ); + }); + + it('discards an interrupted partial and refreshes the starter options', async () => { + mockCommands( + builtinResponses({ + get_starter_options: [ + { ...STARTER_OPTION, partial_bytes: 1_000_000_000 }, + ], + }), + ); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + await flush(); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('discard_partial_download', { + sha256: 'sha-balanced', + }); + }); + + it('looks up a pasted repo and downloads the chosen GGUF file', async () => { + mockCommands( + builtinResponses({ + list_hf_repo_ggufs: [ + { file: 'a.gguf', size_bytes: 2_000_000_000 }, + { file: 'b.gguf', size_bytes: 3_000_000_000 }, + ], + }), + ); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + const lookupBtn = screen.getByRole('button', { name: 'Look up' }); + expect(lookupBtn).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Hugging Face repo id'), { + target: { value: ' owner/repo ' }, + }); + expect(lookupBtn).toBeEnabled(); + fireEvent.click(lookupBtn); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('list_hf_repo_ggufs', { + repo: 'owner/repo', + }); + const fileSelect = screen.getByRole('combobox', { + name: 'GGUF file', + }) as HTMLSelectElement; + expect(fileSelect.value).toBe('a.gguf'); + expect(screen.getByText('a.gguf · 2.0 GB')).toBeInTheDocument(); + fireEvent.change(fileSelect, { target: { value: 'b.gguf' } }); + // The repo Download sits after the picker row's Download button. + const downloads = screen.getAllByRole('button', { name: 'Download' }); + fireEvent.click(downloads[downloads.length - 1]); + await flush(); + expect(invokeMock).toHaveBeenCalledWith( + 'download_repo_model', + expect.objectContaining({ repo: 'owner/repo', file: 'b.gguf' }), + ); + }); + + it('shows the empty-repo hint when the lookup finds no GGUF files', async () => { + mockCommands(builtinResponses({ list_hf_repo_ggufs: [] })); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.change(screen.getByLabelText('Hugging Face repo id'), { + target: { value: 'owner/empty' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Look up' })); + await flush(); + expect( + screen.getByText('No GGUF files found in this repo.'), + ).toBeInTheDocument(); + }); + + it('treats a non-array lookup payload as an empty file list', async () => { + mockCommands(builtinResponses({ list_hf_repo_ggufs: 'nope' })); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.change(screen.getByLabelText('Hugging Face repo id'), { + target: { value: 'owner/odd' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Look up' })); + await flush(); + expect( + screen.getByText('No GGUF files found in this repo.'), + ).toBeInTheDocument(); + }); + + it('surfaces a lookup failure as an inline error', async () => { + mockCommands( + builtinResponses({ + list_hf_repo_ggufs: new Reject('repo not found'), + }), + ); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.change(screen.getByLabelText('Hugging Face repo id'), { + target: { value: 'owner/missing' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Look up' })); + await flush(); + expect(screen.getByRole('alert')).toHaveTextContent('repo not found'); + }); +}); + +// ─── OpenAiProviderCard ────────────────────────────────────────────────────── + +describe('OpenAiProviderCard', () => { + async function renderCard( + overrides: Partial = {}, + onSaved: (next: RawAppConfig) => void = () => {}, + resyncToken = 0, + ) { + const view = render( + , + ); + await flush(); + return view; + } + + it('lists models from list_openai_models and commits a selection', async () => { + mockCommands({ + list_openai_models: ['model-a', 'model-b'], + has_provider_api_key: false, + update_provider_field: NEW_CONFIG, + }); + const onSaved = vi.fn(); + await renderCard({}, onSaved); + const select = screen.getByRole('combobox', { + name: 'OpenAI-compatible model', + }) as HTMLSelectElement; + expect(select.value).toBe(''); + expect(screen.getByText('Choose a model')).toBeInTheDocument(); + fireEvent.change(select, { target: { value: 'model-b' } }); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'openai', + field: 'model', + value: 'model-b', + }); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + }); + + it('shows the loading hint while the model probe is in flight', async () => { + mockCommands({ + list_openai_models: new Promise(() => {}), + has_provider_api_key: false, + }); + await renderCard(); + expect(screen.getByText('Loading models…')).toBeInTheDocument(); + }); + + it('shows the error state with Retry when listing fails, then recovers', async () => { + let calls = 0; + mockCommands({ + list_openai_models: () => { + calls += 1; + if (calls === 1) throw new Error('connection refused'); + return ['model-x']; + }, + has_provider_api_key: false, + }); + await renderCard(); + expect(screen.getByText('Couldn’t list models')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('connection refused'); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await flush(); + expect( + screen.getByRole('combobox', { name: 'OpenAI-compatible model' }), + ).toBeInTheDocument(); + expect(screen.getByText('model-x')).toBeInTheDocument(); + }); + + it('shows the empty-inventory hint when the server lists no models', async () => { + mockCommands({ list_openai_models: [], has_provider_api_key: false }); + await renderCard(); + expect( + screen.getByText('No models reported by the server'), + ).toBeInTheDocument(); + }); + + it('treats a non-array model payload as empty', async () => { + mockCommands({ list_openai_models: 'huh', has_provider_api_key: false }); + await renderCard(); + expect( + screen.getByText('No models reported by the server'), + ).toBeInTheDocument(); + }); + + it('keeps the persisted model selectable when the server no longer lists it', async () => { + mockCommands({ + list_openai_models: ['model-a'], + has_provider_api_key: false, + }); + await renderCard({ model: 'retired-model' }); + const select = screen.getByRole('combobox', { + name: 'OpenAI-compatible model', + }) as HTMLSelectElement; + expect(select.value).toBe('retired-model'); + expect(screen.getByText('retired-model')).toBeInTheDocument(); + expect(screen.queryByText('Choose a model')).not.toBeInTheDocument(); + }); + + it('surfaces a model-commit failure inline', async () => { + mockCommands({ + list_openai_models: ['model-a'], + has_provider_api_key: false, + update_provider_field: new Reject({ + kind: 'type_mismatch', + message: 'Model write failed.', + }), + }); + await renderCard(); + fireEvent.change( + screen.getByRole('combobox', { name: 'OpenAI-compatible model' }), + { target: { value: 'model-a' } }, + ); + await flush(); + expect(screen.getByText('Model write failed.')).toBeInTheDocument(); + }); + + it('commits a changed label on blur and ignores non-Enter keys', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: NEW_CONFIG, + }); + const onSaved = vi.fn(); + await renderCard({}, onSaved); + const label = screen.getByLabelText('Provider label'); + fireEvent.focus(label); + fireEvent.change(label, { target: { value: ' My server ' } }); + fireEvent.keyDown(label, { key: 'a' }); + expect(invokeMock).not.toHaveBeenCalledWith( + 'update_provider_field', + expect.anything(), + ); + fireEvent.blur(label); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'openai', + field: 'label', + value: 'My server', + }); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + // The returned config carries no openai row, so the input falls back to + // the committed (trimmed) value. + expect((label as HTMLInputElement).value).toBe('My server'); + }); + + it('heals an empty label commit to the persisted default label', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: configWith({ + ...OPENAI_PROVIDER, + label: 'OpenAI-compatible', + }), + }); + render(); + await flush(); + const label = screen.getByLabelText('Provider label') as HTMLInputElement; + fireEvent.focus(label); + fireEvent.change(label, { target: { value: ' ' } }); + fireEvent.blur(label); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'openai', + field: 'label', + value: '', + }); + expect(label.value).toBe('OpenAI-compatible'); + }); + + it('leaves a refocused label input alone when the commit resolves', async () => { + let resolveUpdate: (cfg: RawAppConfig) => void = () => {}; + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: () => + new Promise((resolve) => { + resolveUpdate = resolve; + }), + }); + await renderCard(); + const label = screen.getByLabelText('Provider label') as HTMLInputElement; + fireEvent.focus(label); + fireEvent.change(label, { target: { value: 'Renamed' } }); + fireEvent.blur(label); + // The user starts typing again while the commit is still in flight. + fireEvent.focus(label); + fireEvent.change(label, { target: { value: 'Typing again' } }); + await act(async () => { + resolveUpdate(configWith({ ...OPENAI_PROVIDER, label: 'Renamed' })); + await Promise.resolve(); + }); + expect(label.value).toBe('Typing again'); + }); + + it('Enter commits the label via blur; an unchanged label does not commit', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: NEW_CONFIG, + }); + await renderCard(); + const label = screen.getByLabelText('Provider label'); + fireEvent.focus(label); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.blur(label); + await flush(); + expect(invokeMock).not.toHaveBeenCalledWith( + 'update_provider_field', + expect.anything(), + ); + fireEvent.focus(label); + fireEvent.change(label, { target: { value: 'Renamed' } }); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.blur(label); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'openai', + field: 'label', + value: 'Renamed', + }); + }); + + it('reverts the label and shows the error when the commit fails', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: new Reject({ + kind: 'type_mismatch', + message: 'Label rejected.', + }), + }); + await renderCard(); + const label = screen.getByLabelText('Provider label') as HTMLInputElement; + fireEvent.change(label, { target: { value: 'Bad' } }); + fireEvent.blur(label); + await flush(); + expect(screen.getByText('Label rejected.')).toBeInTheDocument(); + expect(label.value).toBe('LM Studio'); + }); + + it('commits a changed base URL on blur and warns about non-local URLs', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: NEW_CONFIG, + }); + const onSaved = vi.fn(); + await renderCard({}, onSaved); + const url = screen.getByLabelText('OpenAI-compatible base URL'); + fireEvent.focus(url); + fireEvent.change(url, { target: { value: 'http://example.com:1234' } }); + expect(screen.getByRole('alert')).toHaveTextContent( + /responsible for securing it/, + ); + fireEvent.keyDown(url, { key: 'a' }); + fireEvent.keyDown(url, { key: 'Enter' }); + fireEvent.blur(url); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'openai', + field: 'base_url', + value: 'http://example.com:1234', + }); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + }); + + it('re-lists models after a successful base URL commit', async () => { + let listCalls = 0; + mockCommands({ + list_openai_models: () => { + listCalls += 1; + return listCalls === 1 ? ['old-model'] : ['new-model']; + }, + has_provider_api_key: false, + update_provider_field: configWith({ + ...OPENAI_PROVIDER, + base_url: 'http://127.0.0.1:9999', + }), + }); + render(); + await flush(); + expect(screen.getByText('old-model')).toBeInTheDocument(); + const url = screen.getByLabelText('OpenAI-compatible base URL'); + fireEvent.focus(url); + fireEvent.change(url, { target: { value: 'http://127.0.0.1:9999' } }); + fireEvent.blur(url); + await waitFor(() => expect(listCalls).toBe(2)); + expect(screen.getByText('new-model')).toBeInTheDocument(); + expect(screen.queryByText('old-model')).not.toBeInTheDocument(); + }); + + it('reverts the base URL when the commit fails; unchanged URL never commits', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: new Reject({ + kind: 'type_mismatch', + message: 'Base URL must start with http:// or https://.', + }), + }); + await renderCard(); + const url = screen.getByLabelText( + 'OpenAI-compatible base URL', + ) as HTMLInputElement; + fireEvent.focus(url); + fireEvent.blur(url); + await flush(); + expect(invokeMock).not.toHaveBeenCalledWith( + 'update_provider_field', + expect.anything(), + ); + fireEvent.change(url, { target: { value: 'ftp://nope' } }); + fireEvent.blur(url); + await flush(); + expect( + screen.getByText('Base URL must start with http:// or https://.'), + ).toBeInTheDocument(); + expect(url.value).toBe('http://127.0.0.1:1234'); + // A failed commit reverts the value and must not refetch the model list. + const listCalls = invokeMock.mock.calls.filter( + (c: unknown[]) => c[0] === 'list_openai_models', + ).length; + expect(listCalls).toBe(1); + }); + + it('resyncs label and base URL from the provider when not focused', async () => { + mockCommands({ list_openai_models: [], has_provider_api_key: false }); + const { rerender } = await renderCard(); + rerender( + {}} + />, + ); + expect( + (screen.getByLabelText('Provider label') as HTMLInputElement).value, + ).toBe('Jan'); + expect( + (screen.getByLabelText('OpenAI-compatible base URL') as HTMLInputElement) + .value, + ).toBe('http://127.0.0.1:1337'); + }); + + it('does not overwrite focused fields on resync', async () => { + mockCommands({ list_openai_models: [], has_provider_api_key: false }); + const { rerender } = await renderCard(); + const label = screen.getByLabelText('Provider label') as HTMLInputElement; + const url = screen.getByLabelText( + 'OpenAI-compatible base URL', + ) as HTMLInputElement; + fireEvent.focus(label); + fireEvent.change(label, { target: { value: 'typing label' } }); + fireEvent.focus(url); + fireEvent.change(url, { target: { value: 'http://typing' } }); + rerender( + {}} + />, + ); + expect(label.value).toBe('typing label'); + expect(url.value).toBe('http://typing'); + }); + + it('saves the API key write-only and refreshes the model list', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + set_provider_api_key: undefined, + }); + await renderCard(); + const keyInput = screen.getByPlaceholderText('sk-…') as HTMLInputElement; + const saveBtn = screen.getByRole('button', { name: 'Save key' }); + expect(saveBtn).toBeDisabled(); + fireEvent.change(keyInput, { target: { value: 'sk-test' } }); + expect(saveBtn).toBeEnabled(); + const listCallsBefore = invokeMock.mock.calls.filter( + (c: unknown[]) => c[0] === 'list_openai_models', + ).length; + fireEvent.click(saveBtn); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('set_provider_api_key', { + providerId: 'openai', + key: 'sk-test', + }); + expect(keyInput.value).toBe(''); + expect(screen.getByText('Key saved')).toBeInTheDocument(); + const listCallsAfter = invokeMock.mock.calls.filter( + (c: unknown[]) => c[0] === 'list_openai_models', + ).length; + expect(listCallsAfter).toBe(listCallsBefore + 1); + }); + + it('surfaces a set_provider_api_key failure', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + set_provider_api_key: new Reject('keychain locked'), + }); + await renderCard(); + fireEvent.change(screen.getByPlaceholderText('sk-…'), { + target: { value: 'sk-test' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save key' })); + await flush(); + expect(screen.getByRole('alert')).toHaveTextContent('keychain locked'); + }); + + it('shows Key saved from has_provider_api_key and clears the key', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: true, + clear_provider_api_key: undefined, + }); + await renderCard(); + expect(screen.getByText('Key saved')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Clear key' })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('clear_provider_api_key', { + providerId: 'openai', + }); + expect(screen.queryByText('Key saved')).not.toBeInTheDocument(); + }); + + it('surfaces a clear_provider_api_key failure and keeps the chip', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: true, + clear_provider_api_key: new Reject('keychain locked'), + }); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Clear key' })); + await flush(); + expect(screen.getByRole('alert')).toHaveTextContent('keychain locked'); + expect(screen.getByText('Key saved')).toBeInTheDocument(); + }); + + it('hides the chip when the key probe fails', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: new Reject(new Error('keychain unavailable')), + }); + await renderCard(); + expect(screen.queryByText('Key saved')).not.toBeInTheDocument(); + }); + + it('writes the vision flag through update_provider_field', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: NEW_CONFIG, + }); + const onSaved = vi.fn(); + await renderCard({}, onSaved); + const toggle = screen.getByRole('switch', { + name: 'Model accepts image inputs', + }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + fireEvent.click(toggle); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'openai', + field: 'vision', + value: 'true', + }); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + }); + + it('turns the vision flag off and surfaces a write failure', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + update_provider_field: new Reject({ + kind: 'type_mismatch', + message: 'Vision write failed.', + }), + }); + await renderCard({ vision: true }); + const toggle = screen.getByRole('switch', { + name: 'Model accepts image inputs', + }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(toggle); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('update_provider_field', { + providerId: 'openai', + field: 'vision', + value: 'false', + }); + expect(screen.getByText('Vision write failed.')).toBeInTheDocument(); + }); + + it('removes the provider after an explicit confirm', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + remove_openai_provider: NEW_CONFIG, + }); + const onSaved = vi.fn(); + await renderCard({}, onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Remove provider' })); + expect( + screen.getByText( + 'Remove this provider? Its saved API key is deleted too.', + ), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('remove_openai_provider'); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + }); + + it('cancel keeps the provider; a failed removal closes the confirm row', async () => { + mockCommands({ + list_openai_models: [], + has_provider_api_key: false, + remove_openai_provider: new Reject(new Error('write failed')), + }); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Remove provider' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect( + screen.getByRole('button', { name: 'Remove provider' }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove provider' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + await flush(); + expect( + screen.getByRole('button', { name: 'Remove provider' }), + ).toBeInTheDocument(); + }); +}); + +// ─── AddOpenAiProvider ─────────────────────────────────────────────────────── + +describe('AddOpenAiProvider', () => { + it('expands from the add button and gates Add on a non-empty base URL', () => { + mockCommands({}); + render( {}} />); + fireEvent.click( + screen.getByRole('button', { name: 'Add OpenAI-compatible server' }), + ); + const addBtn = screen.getByRole('button', { name: 'Add' }); + expect(addBtn).toBeDisabled(); + fireEvent.change(screen.getByLabelText('OpenAI-compatible base URL'), { + target: { value: ' ' }, + }); + expect(addBtn).toBeDisabled(); + fireEvent.change(screen.getByLabelText('OpenAI-compatible base URL'), { + target: { value: 'http://example.com:1234' }, + }); + expect(addBtn).toBeEnabled(); + expect(screen.getByRole('alert')).toHaveTextContent( + /responsible for securing it/, + ); + }); + + it('adds the provider and resets the form on success', async () => { + mockCommands({ add_openai_provider: NEW_CONFIG }); + const onSaved = vi.fn(); + render(); + fireEvent.click( + screen.getByRole('button', { name: 'Add OpenAI-compatible server' }), + ); + fireEvent.change(screen.getByLabelText('Provider label'), { + target: { value: 'LM Studio' }, + }); + fireEvent.change(screen.getByLabelText('OpenAI-compatible base URL'), { + target: { value: ' http://127.0.0.1:1234 ' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('add_openai_provider', { + label: 'LM Studio', + baseUrl: 'http://127.0.0.1:1234', + }); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + // Collapsed back to the affordance with cleared fields. + fireEvent.click( + screen.getByRole('button', { name: 'Add OpenAI-compatible server' }), + ); + expect( + (screen.getByLabelText('Provider label') as HTMLInputElement).value, + ).toBe(''); + expect( + (screen.getByLabelText('OpenAI-compatible base URL') as HTMLInputElement) + .value, + ).toBe(''); + }); + + it('shows the backend error when adding fails and Cancel clears it', async () => { + mockCommands({ + add_openai_provider: new Reject({ + kind: 'type_mismatch', + message: 'An OpenAI-compatible provider already exists.', + }), + }); + render( {}} />); + fireEvent.click( + screen.getByRole('button', { name: 'Add OpenAI-compatible server' }), + ); + fireEvent.change(screen.getByLabelText('OpenAI-compatible base URL'), { + target: { value: 'http://127.0.0.1:1234' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + await flush(); + expect( + screen.getByText('An OpenAI-compatible provider already exists.'), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.click( + screen.getByRole('button', { name: 'Add OpenAI-compatible server' }), + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/settings/tabs/ProviderCards.tsx b/src/settings/tabs/ProviderCards.tsx new file mode 100644 index 00000000..a21320b9 --- /dev/null +++ b/src/settings/tabs/ProviderCards.tsx @@ -0,0 +1,740 @@ +/** + * Provider card bodies for the AI tab's Providers panel. + * + * - `BuiltinProviderCard`: installed-model picker plus the shared download + * kit (starter picker + paste-a-repo) for the built-in engine. + * - `OpenAiProviderCard`: editable label/base URL/model for the single + * OpenAI-compatible provider, write-only API key (Keychain), manual vision + * toggle, and removal with confirm. + * - `AddOpenAiProvider`: the inline "add a server" affordance shown while no + * OpenAI-compatible provider exists. + * + * The cards lift every config write back through `onSaved` so the parent's + * `RawAppConfig` snapshot stays in lock-step with disk, mirroring how the + * Ollama URL field in ModelTab behaves. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; + +import { SettingRow, Toggle } from '../components'; +import { configHelp } from '../configHelpers'; +import { describeConfigError } from '../types'; +import { isNonLocalUrl } from '../../utils/isNonLocalUrl'; +import { + StarterPicker, + useStarterOptions, +} from '../../components/StarterPicker'; +import { DownloadProgress } from '../../components/DownloadProgress'; +import { useDownloadModel } from '../../hooks/useDownloadModel'; +import { buildConfirmInfo } from '../../view/onboarding/ModelCheckStep'; +import styles from '../../styles/settings.module.css'; +import type { RawAppConfig, RawProvider } from '../types'; +import type { + HfGgufFile, + InstalledModel, + StarterTier, +} from '../../types/starter'; + +/** Bytes rendered as decimal gigabytes with one decimal (e.g. "8.2"). */ +function gb(bytes: number): string { + return (bytes / 1e9).toFixed(1); +} + +/** Shared remote-URL caution, same mechanism as the Ollama URL warning. */ +function NonLocalWarning() { + return ( +

+ This points Thuki at a non-local server. You are responsible for securing + it: prefer a VPN/Tailscale or SSH tunnel over exposing the port directly. +

+ ); +} + +// ─── Built-in (Thuki) card body ────────────────────────────────────────────── + +interface BuiltinProviderCardProps { + config: RawAppConfig; + onSaved: (next: RawAppConfig) => void; +} + +export function BuiltinProviderCard({ + config, + onSaved, +}: BuiltinProviderCardProps) { + const builtinModel = + config.inference.providers.find((p) => p.kind === 'builtin')?.model ?? ''; + + const [installed, setInstalled] = useState([]); + const [downloadOpen, setDownloadOpen] = useState(false); + const [selected, setSelected] = useState('balanced'); + const [freeDiskBytes, setFreeDiskBytes] = useState(null); + + // Paste-a-repo flow: id input -> Look up -> file dropdown -> Download. + const [repoId, setRepoId] = useState(''); + const [repoFiles, setRepoFiles] = useState(null); + const [repoFile, setRepoFile] = useState(''); + const [repoError, setRepoError] = useState(null); + + const { options, refresh } = useStarterOptions(); + const { + state, + progress, + etaSeconds, + beginConfirm, + cancelConfirm, + start, + startRepo, + cancel, + retry, + resume, + discard, + enterResumePending, + } = useDownloadModel(); + + const refreshInstalled = useCallback(async () => { + try { + const rows = await invoke('list_installed_models'); + setInstalled(Array.isArray(rows) ? rows : []); + } catch { + setInstalled([]); + } + }, []); + + useEffect(() => { + void refreshInstalled(); + void invoke('get_models_dir_free_bytes') + .then((bytes) => { + setFreeDiskBytes(typeof bytes === 'number' ? bytes : null); + }) + .catch(() => { + // Unknown free space hides the disk line; never blocks the download. + }); + }, [refreshInstalled]); + + // An interrupted earlier download leaves a resumable partial: surface the + // per-card Resume/Discard pair instead of the plain Download button. + useEffect(() => { + if ( + downloadOpen && + state.phase === 'idle' && + options !== null && + options.some((o) => o.partial_bytes !== null) + ) { + enterResumePending(); + } + }, [downloadOpen, state.phase, options, enterResumePending]); + + // Download finished: the backend already wrote the builtin provider's + // model field, so refresh the rows and lift the new config snapshot. + useEffect(() => { + if (state.phase !== 'ready') return; + void (async () => { + await refresh(); + await refreshInstalled(); + try { + onSaved(await invoke('get_config')); + } catch { + // The focus-driven resync picks the change up on next activation. + } + })(); + }, [state.phase, refresh, refreshInstalled, onSaved]); + + function commitModel(id: string) { + void invoke('update_provider_field', { + providerId: 'builtin', + field: 'model', + value: id, + }) + .then(onSaved) + .catch(() => { + // The dropdown re-seeds from config on the next resync. + }); + } + + async function handleLookup() { + setRepoError(null); + setRepoFiles(null); + try { + const rows = await invoke('list_hf_repo_ggufs', { + repo: repoId.trim(), + }); + const files = Array.isArray(rows) ? rows : []; + setRepoFiles(files); + setRepoFile(files[0]?.file ?? ''); + } catch (err) { + setRepoError(String(err)); + } + } + + const modelValue = installed.some((m) => m.id === builtinModel) + ? builtinModel + : ''; + const pickerVisible = + state.phase === 'idle' || + state.phase === 'confirming' || + state.phase === 'resume_pending'; + + return ( + <> + + {installed.length > 0 ? ( + + ) : ( + No models downloaded yet + )} + + + + + {downloadOpen && options !== null ? ( +
+ {pickerVisible ? ( + { + setSelected(tier); + beginConfirm(tier); + }} + onResume={(tier) => { + setSelected(tier); + void resume(tier); + }} + onDiscard={(sha256) => { + void discard(sha256).then(refresh); + }} + /> + ) : null} + void start(selected)} + onCancelConfirm={cancelConfirm} + onCancel={() => void cancel()} + onRetry={() => void retry()} + /> + +
+ setRepoId(e.target.value)} + /> + +
+ {repoError !== null ? ( +

+ {repoError} +

+ ) : null} + {repoFiles !== null && repoFiles.length === 0 ? ( +

+ No GGUF files found in this repo. +

+ ) : null} + {repoFiles !== null && repoFiles.length > 0 ? ( +
+ + +
+ ) : null} +
+ ) : null} + + ); +} + +// ─── OpenAI-compatible card body ───────────────────────────────────────────── + +interface OpenAiProviderCardProps { + provider: RawProvider; + resyncToken: number; + onSaved: (next: RawAppConfig) => void; +} + +export function OpenAiProviderCard({ + provider, + resyncToken, + onSaved, +}: OpenAiProviderCardProps) { + const [label, setLabel] = useState(provider.label); + const labelFocusedRef = useRef(false); + const [baseUrl, setBaseUrl] = useState(provider.base_url); + const baseUrlFocusedRef = useRef(false); + const [fieldError, setFieldError] = useState(null); + + const [models, setModels] = useState(null); + const [modelsError, setModelsError] = useState(null); + + const [apiKey, setApiKey] = useState(''); + const [hasKey, setHasKey] = useState(false); + const [keyError, setKeyError] = useState(null); + const [confirmingRemove, setConfirmingRemove] = useState(false); + + const prevTokenRef = useRef(resyncToken); + if (prevTokenRef.current !== resyncToken) { + prevTokenRef.current = resyncToken; + if (!labelFocusedRef.current) setLabel(provider.label); + if (!baseUrlFocusedRef.current) setBaseUrl(provider.base_url); + } + + const refreshModels = useCallback(async () => { + setModelsError(null); + try { + const rows = await invoke('list_openai_models'); + setModels(Array.isArray(rows) ? rows : []); + } catch (err) { + setModels(null); + setModelsError(String(err)); + } + }, []); + + // `provider.base_url` in the deps re-lists after a successful base URL + // commit (the parent lifts the new config, which changes the prop), so the + // dropdown never keeps offering the old server's models. A failed commit + // reverts locally without touching the prop, so it never refetches. + useEffect(() => { + void refreshModels(); + }, [refreshModels, provider.base_url]); + + useEffect(() => { + void invoke('has_provider_api_key', { providerId: provider.id }) + .then((v) => setHasKey(v === true)) + .catch(() => { + // Unknown key state just hides the chip. + }); + }, [provider.id]); + + function commitField( + field: 'label' | 'base_url' | 'model' | 'vision', + value: string, + revert: () => void, + onSuccess?: (cfg: RawAppConfig) => void, + ) { + void invoke('update_provider_field', { + providerId: provider.id, + field, + value, + }) + .then((cfg) => { + setFieldError(null); + onSaved(cfg); + onSuccess?.(cfg); + }) + .catch((err) => { + setFieldError(describeConfigError(err)); + revert(); + }); + } + + function commitLabel() { + const next = label.trim(); + if (next === provider.label) return; + // The backend heals an empty label to its compiled default; resync the + // unfocused input to whatever actually persisted. + commitField( + 'label', + next, + () => setLabel(provider.label), + (cfg) => { + if (labelFocusedRef.current) return; + const saved = cfg.inference.providers.find((p) => p.id === provider.id); + setLabel(saved ? saved.label : next); + }, + ); + } + + function commitBaseUrl() { + const next = baseUrl.trim(); + if (next === provider.base_url) return; + commitField('base_url', next, () => setBaseUrl(provider.base_url)); + } + + function saveKey() { + void invoke('set_provider_api_key', { + providerId: provider.id, + key: apiKey, + }) + .then(() => { + setApiKey(''); + setHasKey(true); + setKeyError(null); + // The key affects what the server lists; refresh with auth applied. + void refreshModels(); + }) + .catch((err) => setKeyError(String(err))); + } + + function clearKey() { + void invoke('clear_provider_api_key', { providerId: provider.id }) + .then(() => { + setHasKey(false); + setKeyError(null); + void refreshModels(); + }) + .catch((err) => setKeyError(String(err))); + } + + function removeProvider() { + void invoke('remove_openai_provider') + .then(onSaved) + .catch(() => setConfirmingRemove(false)); + } + + // The persisted model may no longer be listed by the server; keep it + // selectable so the dropdown reflects what chat actually uses. + const modelOptions = + models !== null && provider.model !== '' && !models.includes(provider.model) + ? [provider.model, ...models] + : (models ?? []); + + return ( + <> + + { + labelFocusedRef.current = true; + }} + onChange={(e) => setLabel(e.target.value)} + onBlur={() => { + labelFocusedRef.current = false; + commitLabel(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + /> + + + + { + baseUrlFocusedRef.current = true; + }} + onChange={(e) => setBaseUrl(e.target.value)} + onBlur={() => { + baseUrlFocusedRef.current = false; + commitBaseUrl(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + /> + + {isNonLocalUrl(baseUrl) ? : null} + {fieldError !== null ? ( +

+ {fieldError} +

+ ) : null} + + + {models === null && modelsError === null ? ( + Loading models… + ) : modelsError !== null ? ( + Couldn’t list models + ) : modelOptions.length === 0 ? ( + + No models reported by the server + + ) : ( + + )} + + {modelsError !== null ? ( +

+ {modelsError}{' '} + +

+ ) : null} + + +
+ setApiKey(e.target.value)} + /> + + {hasKey ? ( + <> + Key saved + + + ) : null} +
+
+ {keyError !== null ? ( +

+ {keyError} +

+ ) : null} + + + + commitField('vision', next ? 'true' : 'false', () => {}) + } + ariaLabel="Model accepts image inputs" + /> + + +
+ {confirmingRemove ? ( + <> + + Remove this provider? Its saved API key is deleted too. + + + + + ) : ( + + )} +
+ + ); +} + +// ─── Add affordance (no OpenAI-compatible provider configured) ─────────────── + +interface AddOpenAiProviderProps { + onSaved: (next: RawAppConfig) => void; +} + +export function AddOpenAiProvider({ onSaved }: AddOpenAiProviderProps) { + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [error, setError] = useState(null); + + function handleAdd() { + void invoke('add_openai_provider', { + label, + baseUrl: baseUrl.trim(), + }) + .then((cfg) => { + setOpen(false); + setLabel(''); + setBaseUrl(''); + setError(null); + onSaved(cfg); + }) + .catch((err) => setError(describeConfigError(err))); + } + + if (!open) { + return ( +
+ +
+ ); + } + + return ( +
+ OpenAI-compatible server + + setLabel(e.target.value)} + /> + + + setBaseUrl(e.target.value)} + /> + + {isNonLocalUrl(baseUrl) ? : null} + {error !== null ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/settings/tabs/tabs.test.tsx b/src/settings/tabs/tabs.test.tsx index 76d8e218..3dd3f066 100644 --- a/src/settings/tabs/tabs.test.tsx +++ b/src/settings/tabs/tabs.test.tsx @@ -94,6 +94,38 @@ const CONFIG: RawAppConfig = { }, }; +/** CONFIG with the built-in provider active (Idle Unload replaces Keep Warm). */ +const BUILTIN_ACTIVE_CONFIG: RawAppConfig = { + ...CONFIG, + inference: { ...CONFIG.inference, active_provider: 'builtin' }, +}; + +/** CONFIG plus the single OpenAI-compatible provider record. */ +const OPENAI_CONFIG: RawAppConfig = { + ...CONFIG, + inference: { + ...CONFIG.inference, + providers: [ + ...CONFIG.inference.providers, + { + id: 'openai', + kind: 'openai', + label: 'LM Studio', + base_url: 'http://127.0.0.1:1234', + model: '', + vision: false, + }, + ], + }, +}; + +/** Full engine lifecycle payload for `engine:status` emissions. */ +function engineStatus( + state: 'stopped' | 'starting' | 'loaded' | 'stopping' | 'failed', +) { + return { state, model_path: '', port: null, error: null }; +} + beforeEach(() => { invokeMock.mockReset(); invokeMock.mockImplementation((cmd: string) => { @@ -133,9 +165,12 @@ describe('ModelTab', () => { await renderModelTab(); expect(screen.getByText('Providers')).toBeInTheDocument(); expect(screen.getByText('Built-in (Thuki)')).toBeInTheDocument(); + // Built-in is selectable (no more "upcoming version" badge); Ollama is + // the active provider in this config. expect( - screen.getByText('Available in an upcoming version'), - ).toBeInTheDocument(); + screen.getByRole('radio', { name: 'Use Built-in (Thuki)' }), + ).not.toBeChecked(); + expect(screen.getByRole('radio', { name: 'Use Ollama' })).toBeChecked(); expect(screen.getByText('Prompt')).toBeInTheDocument(); expect(screen.getByText('Ollama URL')).toBeInTheDocument(); expect(screen.getByText('System prompt')).toBeInTheDocument(); @@ -961,6 +996,264 @@ describe('ModelTab', () => { }); expect(toggle).toHaveAttribute('aria-checked', 'true'); }); + + // ─── Providers panel: radio selection ─────────────────────────────────── + + it('selecting the Built-in radio invokes set_active_provider and lifts the config', async () => { + const onSaved = vi.fn(); + render(); + await act(async () => { + await Promise.resolve(); + }); + fireEvent.click( + screen.getByRole('radio', { name: 'Use Built-in (Thuki)' }), + ); + await act(async () => { + await Promise.resolve(); + }); + expect(invokeMock).toHaveBeenCalledWith('set_active_provider', { + providerId: 'builtin', + }); + expect(onSaved).toHaveBeenCalledWith(CONFIG); + }); + + it('falls back to the literal builtin id and label when no builtin provider is configured', async () => { + const noBuiltin: RawAppConfig = { + ...CONFIG, + inference: { + ...CONFIG.inference, + providers: [CONFIG.inference.providers[1]], + }, + }; + render( {}} />); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText('Built-in (Thuki)')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('radio', { name: 'Use Built-in (Thuki)' }), + ); + await act(async () => { + await Promise.resolve(); + }); + expect(invokeMock).toHaveBeenCalledWith('set_active_provider', { + providerId: 'builtin', + }); + }); + + it('selecting the Ollama radio invokes set_active_provider with the ollama id', async () => { + const onSaved = vi.fn(); + render( + , + ); + await act(async () => { + await Promise.resolve(); + }); + fireEvent.click(screen.getByRole('radio', { name: 'Use Ollama' })); + await act(async () => { + await Promise.resolve(); + }); + expect(invokeMock).toHaveBeenCalledWith('set_active_provider', { + providerId: 'ollama', + }); + expect(onSaved).toHaveBeenCalledWith(CONFIG); + }); + + it('swallows a set_active_provider failure without crashing', async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'get_loaded_model') return Promise.resolve(null); + if (cmd === 'get_model_picker_state') + return Promise.resolve({ + active: null, + all: [], + ollamaReachable: false, + }); + if (cmd === 'set_active_provider') + return Promise.reject(new Error('write failed')); + return Promise.resolve(CONFIG); + }); + const onSaved = vi.fn(); + render(); + await act(async () => { + await Promise.resolve(); + }); + fireEvent.click( + screen.getByRole('radio', { name: 'Use Built-in (Thuki)' }), + ); + await act(async () => { + await Promise.resolve(); + }); + expect(onSaved).not.toHaveBeenCalled(); + expect( + screen.getByRole('radio', { name: 'Use Built-in (Thuki)' }), + ).toBeInTheDocument(); + }); + + it('renders the OpenAI-compatible card when configured and selects it via its radio', async () => { + render( + {}} />, + ); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText('LM Studio')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add OpenAI-compatible server' }), + ).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole('radio', { name: 'Use OpenAI-compatible server' }), + ); + await act(async () => { + await Promise.resolve(); + }); + expect(invokeMock).toHaveBeenCalledWith('set_active_provider', { + providerId: 'openai', + }); + }); + + // ─── Idle Unload (built-in provider active) ───────────────────────────── + + async function renderBuiltinActive( + onSaved: (next: RawAppConfig) => void = () => {}, + ) { + const view = render( + , + ); + await act(async () => { + await Promise.resolve(); + }); + return view; + } + + it('renders Idle Unload instead of Keep Warm when the built-in provider is active', async () => { + await renderBuiltinActive(); + expect(screen.getByText('Idle Unload')).toBeInTheDocument(); + expect(screen.queryByText('Keep Warm')).not.toBeInTheDocument(); + expect(screen.getByText('Engine: stopped')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unload now' })).toBeDisabled(); + }); + + it('clamps the idle minutes input to the 0..1440 range', async () => { + await renderBuiltinActive(); + const input = screen.getByRole('spinbutton', { + name: 'Unload after N idle minutes', + }) as HTMLInputElement; + fireEvent.change(input, { target: { value: '45' } }); + expect(input.value).toBe('45'); + fireEvent.change(input, { target: { value: '-5' } }); + expect(input.value).toBe('0'); + fireEvent.change(input, { target: { value: '99999' } }); + expect(input.value).toBe('1440'); + }); + + it('allows empty idle input mid-edit; blur defaults to 0', async () => { + await renderBuiltinActive(); + const input = screen.getByRole('spinbutton', { + name: 'Unload after N idle minutes', + }) as HTMLInputElement; + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + expect(input.value).toBe(''); + fireEvent.blur(input); + expect(input.value).toBe('0'); + }); + + it('blur with a valid idle value does not reset the field', async () => { + await renderBuiltinActive(); + const input = screen.getByRole('spinbutton', { + name: 'Unload after N idle minutes', + }) as HTMLInputElement; + fireEvent.change(input, { target: { value: '30' } }); + fireEvent.blur(input); + expect(input.value).toBe('30'); + }); + + it('resync does not overwrite the idle minutes input while focused', async () => { + const { rerender } = await renderBuiltinActive(); + const input = screen.getByRole('spinbutton', { + name: 'Unload after N idle minutes', + }) as HTMLInputElement; + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '25' } }); + const updatedConfig: RawAppConfig = { + ...BUILTIN_ACTIVE_CONFIG, + inference: { + ...BUILTIN_ACTIVE_CONFIG.inference, + idle_unload_minutes: 90, + }, + }; + rerender( + {}} />, + ); + expect(input.value).toBe('25'); + }); + + it('engine:status loaded enables Unload now and clicking invokes evict_model', async () => { + await renderBuiltinActive(); + act(() => { + emitTauriEvent('engine:status', engineStatus('loaded')); + }); + expect(screen.getByText('Engine: loaded')).toBeInTheDocument(); + const btn = screen.getByRole('button', { name: 'Unload now' }); + expect(btn).toBeEnabled(); + fireEvent.click(btn); + await act(async () => { + await Promise.resolve(); + }); + expect(invokeMock).toHaveBeenCalledWith('evict_model'); + }); + + it('swallows an evict_model failure from the engine Unload now button', async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'get_loaded_model') return Promise.resolve(null); + if (cmd === 'get_model_picker_state') + return Promise.resolve({ + active: null, + all: [], + ollamaReachable: false, + }); + if (cmd === 'evict_model') + return Promise.reject(new Error('no engine running')); + return Promise.resolve(CONFIG); + }); + await renderBuiltinActive(); + act(() => { + emitTauriEvent('engine:status', engineStatus('loaded')); + }); + fireEvent.click(screen.getByRole('button', { name: 'Unload now' })); + await act(async () => { + await Promise.resolve(); + }); + // The residency line is event-driven, so a failed eviction changes nothing. + expect(screen.getByText('Engine: loaded')).toBeInTheDocument(); + }); + + // ─── Context slider "Applying" hint ───────────────────────────────────── + + it('shows the Applying hint while the engine starts or stops and hides it otherwise', async () => { + await renderBuiltinActive(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + act(() => { + emitTauriEvent('engine:status', engineStatus('starting')); + }); + expect(screen.getByRole('status')).toHaveTextContent(/Applying/); + act(() => { + emitTauriEvent('engine:status', engineStatus('stopping')); + }); + expect(screen.getByRole('status')).toHaveTextContent(/Applying/); + act(() => { + emitTauriEvent('engine:status', engineStatus('loaded')); + }); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); }); describe('DisplayTab', () => { diff --git a/src/styles/settings.module.css b/src/styles/settings.module.css index bad187bc..bb45add9 100644 --- a/src/styles/settings.module.css +++ b/src/styles/settings.module.css @@ -1578,3 +1578,57 @@ font-size: 12px; color: rgba(255, 255, 255, 0.5); } +.providerCard { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); +} +.providerCard + .providerCard { + margin-top: 10px; +} +.providerCardActive { + border-color: rgba(255, 141, 92, 0.4); + background: rgba(255, 141, 92, 0.05); +} +.providerSelectRow { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} +.providerRadio { + accent-color: #ff8d5c; + margin: 0; +} +.providerInlineRow { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} +.providerError { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.45; + color: #ef8585; +} +.keySavedChip { + font-size: 11px; + font-weight: 600; + color: #5ec98a; + background: rgba(94, 201, 138, 0.1); + border: 1px solid rgba(94, 201, 138, 0.25); + border-radius: 6px; + padding: 2px 8px; + white-space: nowrap; +} +.engineStatusLine { + font-size: 12px; + color: rgba(255, 255, 255, 0.55); +} +.ctxApplyingHint { + margin-top: 6px; + font-size: 11.5px; + color: #f0b27a; +} diff --git a/src/types/starter.ts b/src/types/starter.ts index 011506de..738b2f0a 100644 --- a/src/types/starter.ts +++ b/src/types/starter.ts @@ -67,6 +67,24 @@ export type DownloadEvent = | { type: 'Cancelled' } | { type: 'Failed'; data: { kind: DownloadFailKind; message: string } }; +/** One installed-model manifest row (`list_installed_models`). Mirrors the + * serde output of `models::manifest::InstalledModel`; only the fields the + * Settings UI consumes are declared. */ +export interface InstalledModel { + /** Stable key: `":"`. Written to the builtin provider's `model` field. */ + id: string; + /** Human-readable label (e.g. the GGUF file stem). */ + display_name: string; + /** Quantisation label (e.g. "Q4_K_M"); empty when unknown. */ + quant: string; +} + +/** One `.gguf` row from `list_hf_repo_ggufs`, for the paste-a-repo browser. */ +export interface HfGgufFile { + file: string; + size_bytes: number; +} + /** Engine lifecycle snapshot published on the `engine:status` event. */ export interface EngineStatus { state: 'stopped' | 'starting' | 'loaded' | 'stopping' | 'failed'; From f1cb4676312b019ad8c5a3b79100b7344d44d7fb Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 11 Jun 2026 23:19:18 -0400 Subject: [PATCH 04/51] feat: make engine error copy provider-aware Signed-off-by: Logan Nguyen --- src-tauri/src/commands.rs | 37 ++++++ src-tauri/src/history.rs | 4 + src-tauri/src/openai.rs | 121 +++++++++++++++++--- src-tauri/src/search/pipeline.rs | 3 + src/components/__tests__/ErrorCard.test.tsx | 45 ++++++++ 5 files changed, 197 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 552ba8bf..e4b9c758 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -306,6 +306,7 @@ pub(crate) async fn stream_builtin_chat( model: model_id, messages, api_key: None, + flavor: crate::openai::V1Flavor::Builtin, }, client, cancel_token, @@ -1144,6 +1145,7 @@ pub async fn ask_model( model: model_name, messages, api_key, + flavor: crate::openai::V1Flavor::Remote, }, &client, cancel_token.clone(), @@ -2140,6 +2142,41 @@ mod tests { assert!(err.message.contains("gemma4:e2b")); } + /// The exact Ollama 404 copy is part of the IPC contract with ErrorCard + /// (the `ollama pull` substring is wrapped in a code element). Pinned + /// byte-for-byte so provider-aware copy work never drifts it. + #[test] + fn classify_http_404_pins_exact_ollama_copy() { + let err = classify_http_error(404, "gemma4:e2b", ""); + assert_eq!( + err.message, + "Model not found\nRun: ollama pull gemma4:e2b in a terminal." + ); + } + + /// The exact Ollama unreachable copy is rendered verbatim by ErrorCard. + /// Pinned byte-for-byte so provider-aware copy work never drifts it. + #[tokio::test] + async fn classify_stream_error_pins_exact_ollama_copy() { + // Bind then drop a listener so the port is closed; the resulting + // reqwest error is a real connect failure. + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let e = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .unwrap_err(); + let err = classify_stream_error(&e); + assert_eq!(err.kind, EngineErrorKind::EngineUnreachable); + assert_eq!( + err.message, + "Ollama isn't running\nStart Ollama and try again." + ); + } + #[test] fn classify_http_404_includes_requested_model_name_in_hint() { let err = classify_http_error(404, "custom:model", ""); diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index 5057420a..c926a486 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -291,6 +291,10 @@ pub(crate) async fn generate_title_text( model, messages: title_messages, api_key: api_key.clone(), + // The transport collapses builtin into a generic /v1 + // server; errors here are discarded anyway (title + // generation has no user-facing error surface). + flavor: crate::openai::V1Flavor::Remote, }, client, cancel_token, diff --git a/src-tauri/src/openai.rs b/src-tauri/src/openai.rs index 13607dd1..c33512a1 100644 --- a/src-tauri/src/openai.rs +++ b/src-tauri/src/openai.rs @@ -14,6 +14,19 @@ use tokio_util::sync::CancellationToken; use crate::commands::{ChatMessage, EngineError, EngineErrorKind, StreamChunk}; use crate::config::defaults::MAX_SSE_LINE_BYTES; +/// Which flavor of `/v1` server a request targets. Decided at the route +/// dispatch (where the provider kind is known) and carried into the error +/// classifiers so user-facing copy matches the provider: the bundled engine +/// speaks about "Thuki's engine" and points at Settings, while any other +/// OpenAI-compatible server keeps provider-neutral wording. +#[derive(Clone, Copy)] +pub enum V1Flavor { + /// The bundled llama-server sidecar at a loopback port. + Builtin, + /// Any other OpenAI-compatible server (an `openai`-kind provider). + Remote, +} + /// Groups the per-request parameters for [`stream_openai_chat`], mirroring /// `OllamaChatParams` on the native path. pub struct OpenAiChatParams { @@ -24,6 +37,8 @@ pub struct OpenAiChatParams { pub messages: Vec, /// Sent as a `Bearer` authorization header when `Some`. pub api_key: Option, + /// Picks the user-facing error copy for this request. + pub flavor: V1Flavor, } /// Error returned by [`request_openai_json`]. Mirrors the classification the @@ -114,15 +129,23 @@ pub(crate) fn to_openai_message(msg: &ChatMessage) -> serde_json::Value { // ─── Error classification ──────────────────────────────────────────────────── -/// Maps a reqwest connection/transport error to a provider-neutral -/// [`EngineError`], mirroring `classify_stream_error` on the native path: +/// Maps a reqwest connection/transport error to an [`EngineError`], +/// mirroring `classify_stream_error` on the native path: /// connect/timeout failures are `EngineUnreachable`, everything else -/// (e.g. a connection reset mid-stream) is `Other`. -fn classify_v1_transport_error(e: &reqwest::Error) -> EngineError { +/// (e.g. a connection reset mid-stream) is `Other`. The unreachable copy +/// branches on `flavor`: the bundled engine is Thuki's own process (the +/// next message re-ensures it), while a remote server keeps neutral wording. +fn classify_v1_transport_error(e: &reqwest::Error, flavor: V1Flavor) -> EngineError { if e.is_connect() || e.is_timeout() { EngineError { kind: EngineErrorKind::EngineUnreachable, - message: format!("The inference server could not be reached.\n{e}"), + message: match flavor { + V1Flavor::Builtin => { + "Thuki's engine isn't running\nSend your message again to restart it." + .to_string() + } + V1Flavor::Remote => format!("The inference server could not be reached.\n{e}"), + }, } } else { EngineError { @@ -134,13 +157,22 @@ fn classify_v1_transport_error(e: &reqwest::Error) -> EngineError { } } -/// Maps a non-2xx HTTP status from a `/v1` server to a provider-neutral -/// [`EngineError`], mirroring `classify_http_error` on the native path. -fn classify_v1_http_error(status: u16, model_name: &str) -> EngineError { +/// Maps a non-2xx HTTP status from a `/v1` server to an [`EngineError`], +/// mirroring `classify_http_error` on the native path. The 404 copy branches +/// on `flavor`: the bundled engine steers the user to the Settings download +/// flow, a remote server names the model it is missing. +fn classify_v1_http_error(status: u16, model_name: &str, flavor: V1Flavor) -> EngineError { match status { 404 => EngineError { kind: EngineErrorKind::ModelNotFound, - message: format!("Model not found\nThe server has no model named '{model_name}'."), + message: match flavor { + V1Flavor::Builtin => { + "Model not found\nPick or download a model in Settings.".to_string() + } + V1Flavor::Remote => { + format!("Model not found\nThe server has no model named '{model_name}'.") + } + }, }, 401 | 403 => EngineError { kind: EngineErrorKind::Other, @@ -188,6 +220,7 @@ pub async fn stream_openai_chat( model, messages, api_key, + flavor, } = params; let body = serde_json::json!({ "model": model, @@ -206,14 +239,16 @@ pub async fn stream_openai_chat( let response = match request.send().await { Ok(response) => response, Err(e) => { - on_chunk(StreamChunk::Error(classify_v1_transport_error(&e))); + on_chunk(StreamChunk::Error(classify_v1_transport_error(&e, flavor))); return accumulated; } }; if !response.status().is_success() { let status = response.status().as_u16(); - on_chunk(StreamChunk::Error(classify_v1_http_error(status, &model))); + on_chunk(StreamChunk::Error(classify_v1_http_error( + status, &model, flavor, + ))); return accumulated; } @@ -285,7 +320,7 @@ pub async fn stream_openai_chat( } } Some(Err(e)) => { - on_chunk(StreamChunk::Error(classify_v1_transport_error(&e))); + on_chunk(StreamChunk::Error(classify_v1_transport_error(&e, flavor))); return accumulated; } None => { @@ -410,6 +445,7 @@ mod tests { model: "test-model".to_string(), messages: vec![user_message("hi")], api_key: None, + flavor: V1Flavor::Remote, } } @@ -658,6 +694,40 @@ mod tests { assert_eq!(accumulated, ""); } + /// Builtin flavor: an unreachable sidecar reads as Thuki's own engine + /// being down, not as a generic "inference server". The full string is + /// pinned: it is rendered verbatim by ErrorCard. + #[tokio::test] + async fn connect_refused_builtin_names_thukis_engine() { + // Bind then drop a listener so the port is closed. + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + + let client = reqwest::Client::new(); + let (chunks, callback) = collect_chunks(); + let accumulated = stream_openai_chat( + OpenAiChatParams { + flavor: V1Flavor::Builtin, + ..chat_params(format!("http://127.0.0.1:{port}")) + }, + &client, + CancellationToken::new(), + callback, + ) + .await; + + let chunks = chunks.lock().unwrap(); + assert_eq!(chunks.len(), 1); + assert!(matches!( + &chunks[0], + StreamChunk::Error(e) if e.kind == EngineErrorKind::EngineUnreachable + && e.message == "Thuki's engine isn't running\nSend your message again to restart it." + )); + assert_eq!(accumulated, ""); + } + #[tokio::test] async fn http_404_maps_model_not_found() { let server = MockServer::start().await; @@ -748,11 +818,36 @@ mod tests { /// 403 takes the same auth branch as 401. #[test] fn http_403_classifies_with_auth_message() { - let error = classify_v1_http_error(403, "m"); + let error = classify_v1_http_error(403, "m", V1Flavor::Remote); assert_eq!(error.kind, EngineErrorKind::Other); assert!(error.message.contains("Authentication failed (HTTP 403)")); } + /// Builtin flavor: a 404 steers the user to the Settings download flow + /// (the bundled engine has no server-side model listing to consult). + /// The full string is pinned: it is rendered verbatim by ErrorCard. + #[test] + fn http_404_builtin_points_at_settings() { + let error = classify_v1_http_error(404, "org/repo:m.gguf", V1Flavor::Builtin); + assert_eq!(error.kind, EngineErrorKind::ModelNotFound); + assert_eq!( + error.message, + "Model not found\nPick or download a model in Settings." + ); + } + + /// Remote flavor: the 404 copy names the model the server is missing. + /// Pinned byte-for-byte so builtin copy work never drifts it. + #[test] + fn http_404_remote_names_the_missing_model() { + let error = classify_v1_http_error(404, "test-model", V1Flavor::Remote); + assert_eq!(error.kind, EngineErrorKind::ModelNotFound); + assert_eq!( + error.message, + "Model not found\nThe server has no model named 'test-model'." + ); + } + #[tokio::test] async fn cancel_emits_cancelled() { let server = MockServer::start().await; diff --git a/src-tauri/src/search/pipeline.rs b/src-tauri/src/search/pipeline.rs index 7e8c41fe..a52f7104 100644 --- a/src-tauri/src/search/pipeline.rs +++ b/src-tauri/src/search/pipeline.rs @@ -511,6 +511,9 @@ async fn run_streaming_branch( model: model.to_string(), messages, api_key: api_key.clone(), + // The transport collapses builtin into a generic /v1 + // server, so the neutral remote copy applies here. + flavor: crate::openai::V1Flavor::Remote, }, client, cancel_token, diff --git a/src/components/__tests__/ErrorCard.test.tsx b/src/components/__tests__/ErrorCard.test.tsx index bef3a993..18a54df2 100644 --- a/src/components/__tests__/ErrorCard.test.tsx +++ b/src/components/__tests__/ErrorCard.test.tsx @@ -88,4 +88,49 @@ describe('ErrorCard', () => { expect(code).not.toBeNull(); expect(code?.textContent).toContain('ollama pull gemma3:4b'); }); + + // The strings below pin the backend's provider-aware copy contract: + // Rust owns the wording, ErrorCard renders it verbatim. + + it('renders the builtin EngineUnreachable copy (title and subtitle)', () => { + render( + , + ); + expect( + screen.getByText("Thuki's engine isn't running"), + ).toBeInTheDocument(); + expect( + screen.getByText('Send your message again to restart it.'), + ).toBeInTheDocument(); + }); + + it('pins the exact ollama EngineUnreachable copy', () => { + render( + , + ); + expect(screen.getByText("Ollama isn't running")).toBeInTheDocument(); + expect(screen.getByText('Start Ollama and try again.')).toBeInTheDocument(); + }); + + it('renders the builtin ModelNotFound copy without a code element', () => { + const { container } = render( + , + ); + expect( + screen.getByText('Pick or download a model in Settings.'), + ).toBeInTheDocument(); + // No ollama pull command in the builtin copy, so nothing is code-wrapped. + expect(container.querySelector('code')).toBeNull(); + }); }); From cff70933b2a8a779faf77de062ac3305b8efc58a Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 11 Jun 2026 23:45:58 -0400 Subject: [PATCH 05/51] fix: make the chat submit gate and model picker provider-aware Signed-off-by: Logan Nguyen --- src-tauri/src/models/mod.rs | 236 +++++++++++++++--- src/App.tsx | 4 + src/__tests__/App.test.tsx | 97 +++++++ src/settings/tabs/ModelTab.tsx | 29 ++- src/settings/tabs/tabs.test.tsx | 35 +++ .../__tests__/capabilityConflicts.test.ts | 148 ++++++++--- src/utils/capabilityConflicts.ts | 79 +++++- 7 files changed, 535 insertions(+), 93 deletions(-) diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index f388fc06..01cebd5d 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -236,20 +236,78 @@ async fn fetch_installed_model_names_inner( Ok(body.models.into_iter().map(|m| m.name).collect()) } +/// Installed-model inventory for the active provider, plus a reachability +/// flag, routed by provider kind: +/// +/// - `builtin`: the manifest ids passed in by the caller, no network probe. +/// The engine starts on demand per request, so the inventory is always +/// trustworthy and `reachable` is always `true`. +/// - `openai`: the provider's configured model as a single-element list +/// (empty when none is configured yet). No probe either: errors surface +/// at request time, and model management lives in Settings. +/// - anything else (Ollama): probes `{base_url}/api/tags`. A fetch failure +/// collapses into `(empty, false)` so the caller can emit the structured +/// unreachable payload instead of an error string. +/// +/// Extracted from `get_model_picker_state` so the kind routing is testable +/// without a Tauri runtime; the command wrapper only does state plumbing. +pub async fn picker_inventory_for_kind( + client: &reqwest::Client, + kind: &str, + base_url: &str, + provider_model: Option<&str>, + builtin_installed: &[String], +) -> (Vec, bool) { + match kind { + PROVIDER_KIND_BUILTIN => (builtin_installed.to_vec(), true), + PROVIDER_KIND_OPENAI => ( + provider_model + .map(|m| vec![m.to_string()]) + .unwrap_or_default(), + true, + ), + _ => match fetch_installed_model_names(client, base_url).await { + Ok(installed) => (installed, true), + Err(_) => (Vec::new(), false), + }, + } +} + +/// Reads every installed-model id from the manifest. Thin DB wrapper shared +/// by the commands that need the builtin inventory (`get_model_picker_state`, +/// `set_active_model`, `check_model_setup`); the underlying `manifest::list` +/// carries the tested logic. +#[cfg_attr(coverage_nightly, coverage(off))] +fn manifest_model_ids(db: &crate::history::Database) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + Ok(manifest::list(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .map(|m| m.id) + .collect()) +} + /// Returns the currently active model, the full list of installed models, and -/// a flag telling the frontend whether Ollama itself is reachable. +/// a flag telling the frontend whether the active provider's inventory could +/// be read. /// /// Shape: `{ "active": "" | null, "all": ["", ...], "ollamaReachable": bool }`. +/// The wire key stays the legacy camelCase `ollamaReachable` even though the +/// flag is provider-generic now: renaming it would churn the frontend +/// contract for zero behavioral gain. For `builtin` and `openai` providers +/// the flag is always `true` (see [`picker_inventory_for_kind`]). /// /// The command intentionally never propagates a transport / fetch error to /// the frontend. Instead, an unreachable Ollama collapses into a structured /// `{ active: null, all: [], ollamaReachable: false }` payload so the UI can /// distinguish "Ollama is down" from "Ollama is up but has no models" without -/// parsing error strings. The Ok branch coalesces the read + conditional -/// write into a single database critical section to avoid a TOCTOU window -/// where a concurrent `set_active_model` could be clobbered, and refuses to -/// persist when Ollama reports an empty inventory so a partially-up daemon -/// cannot corrupt the persisted choice. +/// parsing error strings. Resolution + conditional persist go through +/// [`resolve_active_model`] and [`should_persist_resolved`], which refuse to +/// persist when the provider reports an empty inventory so a partially-up +/// daemon cannot corrupt the persisted choice. The resolved value (possibly +/// `None` when unreachable or empty) is always mirrored into the in-memory +/// [`ActiveModelState`] so downstream callers (ask_model, search_pipeline) +/// see the same truth as the frontend. #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] pub async fn get_model_picker_state( @@ -257,21 +315,22 @@ pub async fn get_model_picker_state( client: tauri::State<'_, reqwest::Client>, active_model: tauri::State<'_, ActiveModelState>, config: tauri::State<'_, parking_lot::RwLock>, + db: tauri::State<'_, crate::history::Database>, ) -> Result { - let (ollama_url, active_id, persisted) = read_provider_model_context(&config); - let fetch_result = fetch_installed_model_names(&client, &ollama_url).await; - - let installed = match fetch_result { - Ok(installed) => installed, - Err(_) => { - // Mirror the `None` active into the in-memory state so downstream - // callers (ask_model, search_pipeline) see the same truth as the - // frontend: with the provider unreachable, no model is active. - let mut guard = active_model.0.lock().map_err(|e| e.to_string())?; - *guard = None; - return Ok(build_picker_state_payload(None, &[], false)); - } + let (base_url, active_id, persisted, kind) = read_provider_model_context(&config); + let manifest_ids = if kind == PROVIDER_KIND_BUILTIN { + manifest_model_ids(&db)? + } else { + Vec::new() }; + let (installed, reachable) = picker_inventory_for_kind( + &client, + &kind, + &base_url, + persisted.as_deref(), + &manifest_ids, + ) + .await; let resolved = resolve_active_model(persisted.as_deref(), &installed); if let Some(slug) = resolved.as_deref() { @@ -288,22 +347,25 @@ pub async fn get_model_picker_state( Ok(build_picker_state_payload( resolved.as_deref(), &installed, - true, + reachable, )) } -/// Snapshots the active provider's base URL, id, and selected model from the -/// shared config. Returns the model as `Option` (empty -> `None`) so -/// callers can feed it straight into the resolve helpers. +/// Snapshots the active provider's base URL, id, selected model, and kind +/// from the shared config under a single lock read so a concurrent provider +/// switch can never pair fields from different providers. Returns the model +/// as `Option` (empty -> `None`) so callers can feed it straight into +/// the resolve helpers. #[cfg_attr(coverage_nightly, coverage(off))] fn read_provider_model_context( config: &parking_lot::RwLock, -) -> (String, String, Option) { +) -> (String, String, Option, String) { let c = config.read(); ( c.inference.active_provider_base_url().to_string(), c.inference.active_provider.clone(), c.inference.active_provider_model_opt().map(str::to_string), + c.inference.active_provider_kind().to_string(), ) } @@ -375,8 +437,12 @@ pub fn build_picker_state_payload( } /// Persists `model` as the active model after validating its shape and -/// confirming Ollama still reports it as installed. Rejects uninstalled -/// slugs with an error that starts with [`MODEL_NOT_INSTALLED_ERR_PREFIX`]. +/// confirming the active provider still serves it. The validation source is +/// routed by provider kind exactly like [`picker_inventory_for_kind`]: the +/// builtin manifest and the openai configured model never touch the network, +/// while the Ollama arm keeps probing `/api/tags` and propagating fetch +/// errors verbatim. Rejects unserved slugs with an error that starts with +/// [`MODEL_NOT_INSTALLED_ERR_PREFIX`]. #[cfg_attr(coverage_nightly, coverage(off))] #[cfg_attr(not(coverage), tauri::command)] pub async fn set_active_model( @@ -385,11 +451,16 @@ pub async fn set_active_model( client: tauri::State<'_, reqwest::Client>, active_model: tauri::State<'_, ActiveModelState>, config: tauri::State<'_, parking_lot::RwLock>, + db: tauri::State<'_, crate::history::Database>, ) -> Result<(), String> { validate_model_slug(&model)?; - let (ollama_url, active_id, _persisted) = read_provider_model_context(&config); - let installed = fetch_installed_model_names(&client, &ollama_url).await?; + let (ollama_url, active_id, persisted, kind) = read_provider_model_context(&config); + let installed: Vec = match kind.as_str() { + PROVIDER_KIND_BUILTIN => manifest_model_ids(&db)?, + PROVIDER_KIND_OPENAI => persisted.into_iter().collect(), + _ => fetch_installed_model_names(&client, &ollama_url).await?, + }; validate_model_installed(&model, &installed)?; persist_active_provider_model(&app, &config, &active_id, &model)?; @@ -569,19 +640,11 @@ pub async fn check_model_setup( config: tauri::State<'_, parking_lot::RwLock>, db: tauri::State<'_, crate::history::Database>, ) -> Result { - let (ollama_url, active_id, persisted) = read_provider_model_context(&config); - let kind = config.read().inference.active_provider_kind().to_string(); + let (ollama_url, active_id, persisted, kind) = read_provider_model_context(&config); let state = match kind.as_str() { PROVIDER_KIND_BUILTIN => { - let ids: Vec = { - let conn = db.0.lock().map_err(|e| e.to_string())?; - manifest::list(&conn) - .map_err(|e| e.to_string())? - .into_iter() - .map(|m| m.id) - .collect() - }; + let ids = manifest_model_ids(&db)?; derive_builtin_setup_state(persisted.as_deref(), &ids) } PROVIDER_KIND_OPENAI => derive_openai_setup_state(persisted.as_deref()), @@ -1881,6 +1944,103 @@ mod tests { assert_eq!(payload["ollamaReachable"], serde_json::Value::Bool(true)); } + // ── picker_inventory_for_kind ──────────────────────────────────────────── + + #[tokio::test] + async fn picker_inventory_builtin_serves_manifest_without_probing() { + // The base URL is unroutable on purpose: if the builtin arm ever + // probed the network it would collapse into the unreachable shape. + // Getting the manifest back with reachable=true proves the builtin + // inventory never leaves the process. + let client = reqwest::Client::new(); + let ids = vec!["tinyllama-1.1b".to_string(), "qwen2.5-0.5b".to_string()]; + let (installed, reachable) = picker_inventory_for_kind( + &client, + PROVIDER_KIND_BUILTIN, + "http://127.0.0.1:1", + Some("tinyllama-1.1b"), + &ids, + ) + .await; + assert_eq!(installed, ids); + assert!(reachable); + } + + #[tokio::test] + async fn picker_inventory_builtin_empty_manifest_stays_reachable() { + // Zero downloaded models is a "go download one" state, never an + // "engine down" state: the frontend routes on the flag. + let client = reqwest::Client::new(); + let (installed, reachable) = + picker_inventory_for_kind(&client, PROVIDER_KIND_BUILTIN, "", None, &[]).await; + assert!(installed.is_empty()); + assert!(reachable); + } + + #[tokio::test] + async fn picker_inventory_openai_lists_configured_model() { + // The unroutable base URL doubles as the no-probe assertion for the + // openai arm too. + let client = reqwest::Client::new(); + let (installed, reachable) = picker_inventory_for_kind( + &client, + PROVIDER_KIND_OPENAI, + "http://127.0.0.1:1", + Some("gpt-4o-mini"), + &[], + ) + .await; + assert_eq!(installed, vec!["gpt-4o-mini".to_string()]); + assert!(reachable); + } + + #[tokio::test] + async fn picker_inventory_openai_empty_when_no_model_configured() { + let client = reqwest::Client::new(); + let (installed, reachable) = + picker_inventory_for_kind(&client, PROVIDER_KIND_OPENAI, "", None, &[]).await; + assert!(installed.is_empty()); + assert!(reachable); + } + + #[tokio::test] + async fn picker_inventory_ollama_probes_tags_endpoint() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/api/tags") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"models":[{"name":"gemma4:e2b"}]}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let (installed, reachable) = + picker_inventory_for_kind(&client, PROVIDER_KIND_OLLAMA, &server.url(), None, &[]) + .await; + + mock.assert_async().await; + assert_eq!(installed, vec!["gemma4:e2b".to_string()]); + assert!(reachable); + } + + #[tokio::test] + async fn picker_inventory_ollama_unreachable_collapses_to_empty_and_false() { + // Port 1 refuses connections. The persisted model must not leak into + // the inventory: with the daemon down nothing can be trusted. + let client = reqwest::Client::new(); + let (installed, reachable) = picker_inventory_for_kind( + &client, + PROVIDER_KIND_OLLAMA, + "http://127.0.0.1:1", + Some("gemma4:e2b"), + &[], + ) + .await; + assert!(installed.is_empty()); + assert!(!reachable); + } + // ── resolve_active_model ───────────────────────────────────────────────── #[test] diff --git a/src/App.tsx b/src/App.tsx index 2ff79701..3eb7c46e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2401,6 +2401,7 @@ function App() { ollamaReachable, availableModels.length, activeModel, + config.inference.activeProviderKind, ); if (envMessage !== null) return envMessage; return getCapabilityConflict( @@ -2416,6 +2417,7 @@ function App() { activeModelCapabilities, ollamaReachable, availableModels.length, + config.inference.activeProviderKind, ]); /** @@ -2433,6 +2435,7 @@ function App() { ollamaReachable, availableModels.length, activeModel, + config.inference.activeProviderKind, ); if (envMessage !== null) return true; return isComposeCapabilityConflict( @@ -2445,6 +2448,7 @@ function App() { activeModel, activeModelCapabilities, composeCapabilityState, + config.inference.activeProviderKind, ]); /** diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 7f2840ae..4161f2f4 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -280,6 +280,103 @@ describe('App', () => { expect(strip.textContent).toContain('ollama pull '); }); + it('submits normally when the builtin provider is active with a downloaded model', async () => { + // Regression guard for the builtin gate bug: with the builtin provider + // active, the picker payload reports reachable=true and the manifest + // inventory, so the env gate must let the message through instead of + // blocking with the Ollama copy. + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'tinyllama-1.1b', + all: ['tinyllama-1.1b'], + ollamaReachable: true, + }, + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay(); + + const textarea = getAskInput(); + act(() => { + setAskValue('hello from the builtin engine'); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith( + 'ask_model', + expect.objectContaining({ message: 'hello from the builtin engine' }), + ); + }); + + it('blocks submit with the builtin download copy when no model is downloaded', async () => { + // Builtin provider active, manifest empty: the strip must point at the + // Settings download flow, never at Ollama, and the submit stays gated. + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: null, + all: [], + ollamaReachable: true, + }, + }); + + render( + + + , + ); + await act(async () => {}); + await showOverlay(); + + const strip = screen.getByTestId('capability-mismatch-strip'); + expect(strip.textContent).toContain('No model downloaded yet'); + expect(strip.textContent).not.toContain('Ollama'); + + const textarea = getAskInput(); + act(() => { + setAskValue('hello'); + }); + invoke.mockClear(); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + + const askInvocations = invoke.mock.calls.filter( + (call) => call[0] === 'ask_model', + ); + expect(askInvocations.length).toBe(0); + // Wait past the 600 ms shake reset so the gate's timeout cleanup runs. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 650)); + }); + }); + it('saves the conversation with the currently selected model', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index bc99c933..79320f63 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -363,18 +363,23 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { over exposing the port directly.

)} - - {availableModels.length > 0 ? ( - void setActiveModel(m)} - ariaLabel="Active Ollama model" - /> - ) : ( - No models installed - )} - + {/* get_model_picker_state is scoped to the ACTIVE provider, so this + inventory only describes Ollama while Ollama is active. Hide the + row otherwise to avoid listing another provider's models here. */} + {activeKind === 'ollama' ? ( + + {availableModels.length > 0 ? ( + void setActiveModel(m)} + ariaLabel="Active Ollama model" + /> + ) : ( + No models installed + )} + + ) : null}
{openaiProvider ? ( diff --git a/src/settings/tabs/tabs.test.tsx b/src/settings/tabs/tabs.test.tsx index 3dd3f066..e2fe51fc 100644 --- a/src/settings/tabs/tabs.test.tsx +++ b/src/settings/tabs/tabs.test.tsx @@ -360,6 +360,41 @@ describe('ModelTab', () => { ).not.toBeInTheDocument(); }); + it('hides the Ollama model row entirely when the built-in provider is active', async () => { + // get_model_picker_state is scoped to the ACTIVE provider, so with the + // built-in active it returns builtin manifest ids. The Ollama card must + // not render that inventory (or the no-models hint) as its own. + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'get_loaded_model') return Promise.resolve(null); + if (cmd === 'get_model_picker_state') { + return Promise.resolve({ + active: 'thuki-starter-4b', + all: ['thuki-starter-4b'], + ollamaReachable: true, + }); + } + return Promise.resolve(BUILTIN_ACTIVE_CONFIG); + }); + render( + {}} + />, + ); + await act(async () => { + await Promise.resolve(); + }); + expect( + screen.queryByRole('combobox', { name: 'Active Ollama model' }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('No models installed')).not.toBeInTheDocument(); + // The rest of the Ollama card stays. + expect( + screen.getByRole('textbox', { name: 'Ollama URL' }), + ).toBeInTheDocument(); + }); + it('shows an empty Ollama URL when no Ollama provider is configured', async () => { const builtinOnly: RawAppConfig = { ...CONFIG, diff --git a/src/utils/__tests__/capabilityConflicts.test.ts b/src/utils/__tests__/capabilityConflicts.test.ts index 9fa8b12a..99b23c19 100644 --- a/src/utils/__tests__/capabilityConflicts.test.ts +++ b/src/utils/__tests__/capabilityConflicts.test.ts @@ -1,11 +1,15 @@ import { describe, it, expect } from 'vitest'; import { + BUILTIN_NO_MODELS_MESSAGE, getCapabilityConflict, getEnvironmentMessage, isComposeCapabilityConflict, + MODEL_STATE_UNAVAILABLE_MESSAGE, NO_MODELS_INSTALLED_MESSAGE, OCR_COMMANDS_DOC_URL, OLLAMA_UNREACHABLE_MESSAGE, + OPENAI_NO_MODEL_MESSAGE, + PICK_A_MODEL_MESSAGE, } from '../capabilityConflicts'; import type { ModelCapabilities } from '../../types/model'; import type { @@ -599,46 +603,122 @@ describe('isComposeCapabilityConflict', () => { }); describe('getEnvironmentMessage', () => { - it('returns the unreachable copy when Ollama cannot be reached (S1)', () => { - // S1: connection refused / timeout / DNS failure. Even if the - // installedCount and activeModel happen to be non-empty (stale state - // from a prior fetch), reachability is the dominant constraint. - expect(getEnvironmentMessage(false, 0, null)).toBe( - OLLAMA_UNREACHABLE_MESSAGE, - ); - }); + describe('ollama provider', () => { + it('returns the unreachable copy when Ollama cannot be reached (S1)', () => { + // S1: connection refused / timeout / DNS failure. Even if the + // installedCount and activeModel happen to be non-empty (stale state + // from a prior fetch), reachability is the dominant constraint. + expect(getEnvironmentMessage(false, 0, null, 'ollama')).toBe( + OLLAMA_UNREACHABLE_MESSAGE, + ); + }); - it('returns the unreachable copy even with stale active/installed values', () => { - expect(getEnvironmentMessage(false, 3, 'gemma4:e4b')).toBe( - OLLAMA_UNREACHABLE_MESSAGE, - ); - }); + it('returns the unreachable copy even with stale active/installed values', () => { + expect(getEnvironmentMessage(false, 3, 'gemma4:e4b', 'ollama')).toBe( + OLLAMA_UNREACHABLE_MESSAGE, + ); + }); - it('returns the no-models copy when reachable but installed list is empty (S2)', () => { - expect(getEnvironmentMessage(true, 0, null)).toBe( - NO_MODELS_INSTALLED_MESSAGE, - ); - }); + it('returns the no-models copy when reachable but installed list is empty (S2)', () => { + expect(getEnvironmentMessage(true, 0, null, 'ollama')).toBe( + NO_MODELS_INSTALLED_MESSAGE, + ); + }); + + it('returns the pick-a-model copy when reachable, models present, none active (S3)', () => { + // S3 is the rare post-Phase-A defensive state. Backend auto-picks the + // first installed model on launch, but if a payload drift ever lands + // here we still surface a clear recovery cue instead of falling + // through to the capability helper with a null model. + const result = getEnvironmentMessage(true, 2, null, 'ollama'); + expect(result).toBe(PICK_A_MODEL_MESSAGE); + expect(result).toBe( + 'Pick a model from the chip above to start chatting.', + ); + }); + + it('returns null when an active model is set so per-message gates can run (S4)', () => { + expect(getEnvironmentMessage(true, 2, 'gemma4:e4b', 'ollama')).toBeNull(); + }); + + it('returns the pick-a-model copy when activeModel is the empty string', () => { + // Empty string is treated as "no active model" so the strip surfaces + // the recovery cue rather than letting the capability helper pretend + // the empty slug is a real selection. + expect(getEnvironmentMessage(true, 1, '', 'ollama')).toBe( + 'Pick a model from the chip above to start chatting.', + ); + }); - it('returns the pick-a-model copy when reachable, models present, none active (S3)', () => { - // S3 is the rare post-Phase-A defensive state. Backend auto-picks the - // first installed model on launch, but if a payload drift ever lands - // here we still surface a clear recovery cue instead of falling - // through to the capability helper with a null model. - const result = getEnvironmentMessage(true, 2, null); - expect(result).toBe('Pick a model from the chip above to start chatting.'); + it('treats an unknown provider kind as ollama (ConfigContext fallback)', () => { + // ConfigContext falls back to 'ollama' when the active-provider + // pointer does not resolve; an unexpected kind string must follow + // the same conservative route rather than silently unblocking. + expect(getEnvironmentMessage(false, 0, null, 'mystery')).toBe( + OLLAMA_UNREACHABLE_MESSAGE, + ); + }); }); - it('returns null when an active model is set so per-message gates can run (S4)', () => { - expect(getEnvironmentMessage(true, 2, 'gemma4:e4b')).toBeNull(); + describe('builtin provider', () => { + it('never shows the Ollama copy: an IPC failure shows the generic model-state copy', () => { + // The backend always reports reachable=true for the builtin engine + // (it starts on demand per request), so reachable=false here means + // the picker IPC call itself failed. Still gate, but never tell a + // builtin user to start Ollama. + expect(getEnvironmentMessage(false, 0, null, 'builtin')).toBe( + MODEL_STATE_UNAVAILABLE_MESSAGE, + ); + }); + + it('points at Settings when no model is downloaded yet', () => { + expect(getEnvironmentMessage(true, 0, null, 'builtin')).toBe( + BUILTIN_NO_MODELS_MESSAGE, + ); + expect(BUILTIN_NO_MODELS_MESSAGE).not.toContain('Ollama'); + expect(BUILTIN_NO_MODELS_MESSAGE).not.toContain('ollama pull'); + }); + + it('returns the pick-a-model copy when models are downloaded but none is active', () => { + expect(getEnvironmentMessage(true, 2, null, 'builtin')).toBe( + PICK_A_MODEL_MESSAGE, + ); + }); + + it('returns null when a downloaded model is active', () => { + expect( + getEnvironmentMessage(true, 1, 'tinyllama-1.1b', 'builtin'), + ).toBeNull(); + }); }); - it('returns the pick-a-model copy when activeModel is the empty string', () => { - // Empty string is treated as "no active model" so the strip surfaces - // the recovery cue rather than letting the capability helper pretend - // the empty slug is a real selection. - expect(getEnvironmentMessage(true, 1, '')).toBe( - 'Pick a model from the chip above to start chatting.', - ); + describe('openai provider', () => { + it('shows the generic model-state copy when the picker IPC call failed', () => { + expect(getEnvironmentMessage(false, 0, null, 'openai')).toBe( + MODEL_STATE_UNAVAILABLE_MESSAGE, + ); + }); + + it('points at Settings when no model is configured', () => { + expect(getEnvironmentMessage(true, 0, null, 'openai')).toBe( + OPENAI_NO_MODEL_MESSAGE, + ); + expect(OPENAI_NO_MODEL_MESSAGE).not.toContain('Ollama'); + }); + + it('points at Settings when models exist but none is active (defensive)', () => { + // The backend derives the openai inventory from the configured model, + // so installed-without-active should not occur; route it to Settings + // anyway because the in-chat picker cannot fix an openai provider. + expect(getEnvironmentMessage(true, 1, null, 'openai')).toBe( + OPENAI_NO_MODEL_MESSAGE, + ); + }); + + it('returns null when the configured model is active', () => { + expect( + getEnvironmentMessage(true, 1, 'gpt-4o-mini', 'openai'), + ).toBeNull(); + }); }); }); diff --git a/src/utils/capabilityConflicts.ts b/src/utils/capabilityConflicts.ts index 0fe06f34..77fb8f6c 100644 --- a/src/utils/capabilityConflicts.ts +++ b/src/utils/capabilityConflicts.ts @@ -95,20 +95,69 @@ export const NO_MODELS_INSTALLED_MESSAGE = export const OLLAMA_UNREACHABLE_MESSAGE = "Ollama isn't running. Start Ollama and try again."; +/** + * Copy used when the built-in engine has no downloaded model yet. The + * recovery action lives in Settings (the download picker), never in an + * `ollama pull`: the builtin provider does not talk to Ollama at all. + */ +export const BUILTIN_NO_MODELS_MESSAGE = + 'No model downloaded yet. Download one in Settings, then come back.'; + +/** + * Copy used when an OpenAI-compatible provider has no model configured. + * The in-chat picker cannot fix this (openai model management lives in + * Settings), so the strip routes the user there. + */ +export const OPENAI_NO_MODEL_MESSAGE = + 'No model set for this provider. Choose one in Settings, then come back.'; + +/** + * Copy used for non-Ollama providers when the model picker state could not + * be loaded at all (the IPC call rejected or returned a malformed payload). + * The backend always reports builtin and openai providers as reachable, so + * this state only occurs on a real transport failure where nothing about + * the environment can be trusted. Deliberately generic: telling a builtin + * user to "start Ollama" would be wrong, and there is no user action more + * specific than retrying. + */ +export const MODEL_STATE_UNAVAILABLE_MESSAGE = + "Thuki couldn't check your models. Try again in a moment."; + +/** + * Copy used when models are installed but none is active yet. The in-chat + * picker chip can fix this directly, so the cue points at it. Shared by the + * ollama and builtin branches of {@link getEnvironmentMessage}. + */ +export const PICK_A_MODEL_MESSAGE = + 'Pick a model from the chip above to start chatting.'; + /** * Picks the right environment-state message to render in * `CapabilityMismatchStrip`, or returns `null` when the environment is * healthy enough that a per-message capability gate should run instead. * - * Three states are distinguished so the strip never tells the user to - * "pull a model" when the actual problem is that Ollama is down: + * The matrix is provider-kind-aware so a builtin or openai user is never + * told to start Ollama or run `ollama pull`: + * + * - `ollama` (and any unknown kind, matching ConfigContext's fallback): + * - S1: Ollama unreachable. Returns the unreachable copy regardless of + * `installedCount` or `activeModel` because we cannot trust either. + * - S2: Ollama reachable, zero models installed. Returns the no-models copy. + * - S3: Ollama reachable, models installed, none active. Returns the + * pick-a-model copy. This state is rare post-Phase-A because the backend + * auto-picks on first launch, but the strip handles it defensively. + * - `builtin`: the backend always reports reachable=true (the engine starts + * on demand per request), so `reachable=false` only means the picker IPC + * call itself failed and the generic model-state copy is shown. Zero + * installed routes to the Settings download picker; none-active reuses the + * pick-a-model cue because the in-chat chips work for builtin models. + * - `openai`: reachable mirrors builtin (errors surface at request time). + * Zero installed and none-active both route to Settings because the + * configured model is the only inventory an openai provider has. * - * - S1: Ollama unreachable. Returns the unreachable copy regardless of - * `installedCount` or `activeModel` because we cannot trust either. - * - S2: Ollama reachable, zero models installed. Returns the no-models copy. - * - S3: Ollama reachable, models installed, none active. Returns the - * pick-a-model copy. This state is rare post-Phase-A because the backend - * auto-picks on first launch, but the strip handles it defensively. + * `reachable` keeps the name `ollamaReachable` at the IPC boundary (the wire + * key on `get_model_picker_state` is legacy camelCase); here it simply means + * "the last picker fetch produced trustworthy state". * * Returns `null` once a model is actually active so callers fall through * to the per-message capability check. @@ -117,11 +166,23 @@ export function getEnvironmentMessage( ollamaReachable: boolean, installedCount: number, activeModel: string | null | undefined, + providerKind: string, ): string | null { + if (providerKind === 'builtin') { + if (!ollamaReachable) return MODEL_STATE_UNAVAILABLE_MESSAGE; + if (installedCount === 0) return BUILTIN_NO_MODELS_MESSAGE; + if (!activeModel) return PICK_A_MODEL_MESSAGE; + return null; + } + if (providerKind === 'openai') { + if (!ollamaReachable) return MODEL_STATE_UNAVAILABLE_MESSAGE; + if (installedCount === 0 || !activeModel) return OPENAI_NO_MODEL_MESSAGE; + return null; + } if (!ollamaReachable) return OLLAMA_UNREACHABLE_MESSAGE; if (installedCount === 0) return NO_MODELS_INSTALLED_MESSAGE; if (!activeModel) { - return 'Pick a model from the chip above to start chatting.'; + return PICK_A_MODEL_MESSAGE; } return null; } From 7d8d4ba151759b67763cdf50635a86d50c2ee343 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 12 Jun 2026 10:20:19 -0400 Subject: [PATCH 06/51] fix: carry provider flavor through search error copy Signed-off-by: Logan Nguyen --- src-tauri/src/commands.rs | 23 +++- src-tauri/src/history.rs | 12 +- src-tauri/src/models/mod.rs | 14 +-- src-tauri/src/openai.rs | 40 +++++-- src-tauri/src/search/llm.rs | 112 ++++++++++++++---- src-tauri/src/search/pipeline.rs | 11 +- src-tauri/src/search/types.rs | 32 ++++- src/__tests__/App.test.tsx | 2 +- .../__tests__/useModelSelection.test.tsx | 6 +- 9 files changed, 193 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e4b9c758..40c29108 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -363,6 +363,10 @@ pub enum LlmTransport { V1 { base_url: String, api_key: Option, + /// Which `/v1` flavor this transport targets, decided where the + /// provider kind is known so downstream error copy matches the + /// provider (builtin vs remote). + flavor: crate::openai::V1Flavor, }, } @@ -383,10 +387,15 @@ impl std::fmt::Debug for LlmTransport { .debug_struct("OllamaNative") .field("endpoint", endpoint) .finish(), - LlmTransport::V1 { base_url, api_key } => f + LlmTransport::V1 { + base_url, + api_key, + flavor, + } => f .debug_struct("V1") .field("base_url", base_url) .field("api_key", &api_key.as_ref().map(|_| "")) + .field("flavor", flavor) .finish(), } } @@ -444,6 +453,7 @@ pub(crate) async fn resolve_llm_transport( } => Ok(LlmTransport::V1 { base_url, api_key: resolve_provider_api_key(secrets, api_key_provider.as_deref()), + flavor: crate::openai::V1Flavor::Remote, }), ChatRoute::Builtin { model_id } => { // Resolve the manifest row inside a scope so the connection guard @@ -461,6 +471,7 @@ pub(crate) async fn resolve_llm_transport( Ok(port) => Ok(LlmTransport::V1 { base_url: format!("http://127.0.0.1:{port}"), api_key: None, + flavor: crate::openai::V1Flavor::Builtin, }), Err(crate::engine::runner::EnsureError::Superseded) => { Err(TransportError::Superseded) @@ -3538,6 +3549,7 @@ mod tests { let v1 = LlmTransport::V1 { base_url: "http://localhost:8080".to_string(), api_key: None, + flavor: crate::openai::V1Flavor::Remote, }; assert_eq!( v1.endpoint_label(), @@ -3550,6 +3562,7 @@ mod tests { let with_key = LlmTransport::V1 { base_url: "https://api.openai.com".to_string(), api_key: Some("sk-supersecret".to_string()), + flavor: crate::openai::V1Flavor::Remote, }; let debug = format!("{with_key:?}"); assert!( @@ -3564,9 +3577,14 @@ mod tests { let no_key = LlmTransport::V1 { base_url: "http://127.0.0.1:8080".to_string(), api_key: None, + flavor: crate::openai::V1Flavor::Builtin, }; let debug_none = format!("{no_key:?}"); assert!(debug_none.contains("None"), "None key must show as None"); + assert!( + debug_none.contains("Builtin"), + "flavor must appear in Debug output" + ); // OllamaNative has no key field; just verify it formats without panic. let native = LlmTransport::OllamaNative { @@ -3688,6 +3706,7 @@ mod tests { LlmTransport::V1 { base_url: "http://localhost:8080".to_string(), api_key: Some("sk-test".to_string()), + flavor: crate::openai::V1Flavor::Remote, } ); engine.shutdown().await; @@ -3728,6 +3747,7 @@ mod tests { LlmTransport::V1 { base_url: "http://127.0.0.1:4242".to_string(), api_key: None, + flavor: crate::openai::V1Flavor::Builtin, } ); // The ensure landed: the engine reports the loaded model. @@ -3807,6 +3827,7 @@ mod tests { LlmTransport::V1 { base_url: "http://127.0.0.1:4243".to_string(), api_key: None, + flavor: crate::openai::V1Flavor::Builtin, } ); engine.shutdown().await; diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index c926a486..1a743b8a 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -284,17 +284,18 @@ pub(crate) async fn generate_title_text( ) .await } - crate::commands::LlmTransport::V1 { base_url, api_key } => { + crate::commands::LlmTransport::V1 { + base_url, + api_key, + flavor, + } => { crate::openai::stream_openai_chat( crate::openai::OpenAiChatParams { base_url: base_url.clone(), model, messages: title_messages, api_key: api_key.clone(), - // The transport collapses builtin into a generic /v1 - // server; errors here are discarded anyway (title - // generation has no user-facing error surface). - flavor: crate::openai::V1Flavor::Remote, + flavor: *flavor, }, client, cancel_token, @@ -658,6 +659,7 @@ mod tests { let transport = crate::commands::LlmTransport::V1 { base_url: server.uri(), api_key: Some("sk-test".to_string()), + flavor: crate::openai::V1Flavor::Remote, }; let accumulated = generate_title_text( &transport, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 01cebd5d..ce82bb0d 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -41,9 +41,10 @@ use crate::config::AppConfig; pub const ACTIVE_MODEL_KEY: &str = "active_model"; /// Shared error-message prefix used when a requested slug is not present in -/// the live Ollama inventory. Exported so the frontend and tests can match -/// against a stable constant instead of a prose string. -pub const MODEL_NOT_INSTALLED_ERR_PREFIX: &str = "Model is not installed in Ollama: "; +/// the active provider's inventory (the live Ollama tags, the builtin +/// manifest, or the openai configured model). Exported so the frontend and +/// tests can match against a stable constant instead of a prose string. +pub const MODEL_NOT_INSTALLED_ERR_PREFIX: &str = "Model is not installed: "; /// In-memory cache of the currently active model slug. Written once at /// startup (after `resolve_seed_active_model`) and updated every time the @@ -2722,10 +2723,9 @@ mod tests { #[test] fn model_not_installed_err_prefix_is_stable() { - assert_eq!( - MODEL_NOT_INSTALLED_ERR_PREFIX, - "Model is not installed in Ollama: " - ); + // Provider-neutral: reachable on builtin (chip click racing a model + // delete) and openai providers, not only Ollama. + assert_eq!(MODEL_NOT_INSTALLED_ERR_PREFIX, "Model is not installed: "); } // ── derive_model_setup_state (Phase 3 onboarding gate) ────────────────── diff --git a/src-tauri/src/openai.rs b/src-tauri/src/openai.rs index c33512a1..6c6dae50 100644 --- a/src-tauri/src/openai.rs +++ b/src-tauri/src/openai.rs @@ -19,7 +19,7 @@ use crate::config::defaults::MAX_SSE_LINE_BYTES; /// classifiers so user-facing copy matches the provider: the bundled engine /// speaks about "Thuki's engine" and points at Settings, while any other /// OpenAI-compatible server keeps provider-neutral wording. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum V1Flavor { /// The bundled llama-server sidecar at a loopback port. Builtin, @@ -137,16 +137,7 @@ pub(crate) fn to_openai_message(msg: &ChatMessage) -> serde_json::Value { /// next message re-ensures it), while a remote server keeps neutral wording. fn classify_v1_transport_error(e: &reqwest::Error, flavor: V1Flavor) -> EngineError { if e.is_connect() || e.is_timeout() { - EngineError { - kind: EngineErrorKind::EngineUnreachable, - message: match flavor { - V1Flavor::Builtin => { - "Thuki's engine isn't running\nSend your message again to restart it." - .to_string() - } - V1Flavor::Remote => format!("The inference server could not be reached.\n{e}"), - }, - } + v1_unreachable_error(&e.to_string(), flavor) } else { EngineError { kind: EngineErrorKind::Other, @@ -157,11 +148,34 @@ fn classify_v1_transport_error(e: &reqwest::Error, flavor: V1Flavor) -> EngineEr } } +/// Copy for an unreachable `/v1` server, keyed by flavor. Shared by the +/// streaming classifier above and the search pipeline's structured-output +/// error mapping so each flavor's unreachable copy lives in exactly one +/// place. The bundled engine is Thuki's own process (the next message +/// re-ensures it); a remote server keeps neutral wording plus the transport +/// detail. +pub(crate) fn v1_unreachable_error(detail: &str, flavor: V1Flavor) -> EngineError { + EngineError { + kind: EngineErrorKind::EngineUnreachable, + message: match flavor { + V1Flavor::Builtin => { + "Thuki's engine isn't running\nSend your message again to restart it.".to_string() + } + V1Flavor::Remote => format!("The inference server could not be reached.\n{detail}"), + }, + } +} + /// Maps a non-2xx HTTP status from a `/v1` server to an [`EngineError`], /// mirroring `classify_http_error` on the native path. The 404 copy branches /// on `flavor`: the bundled engine steers the user to the Settings download -/// flow, a remote server names the model it is missing. -fn classify_v1_http_error(status: u16, model_name: &str, flavor: V1Flavor) -> EngineError { +/// flow, a remote server names the model it is missing. Shared with the +/// search pipeline's structured-output error mapping. +pub(crate) fn classify_v1_http_error( + status: u16, + model_name: &str, + flavor: V1Flavor, +) -> EngineError { match status { 404 => EngineError { kind: EngineErrorKind::ModelNotFound, diff --git a/src-tauri/src/search/llm.rs b/src-tauri/src/search/llm.rs index f030d1aa..52829d10 100644 --- a/src-tauri/src/search/llm.rs +++ b/src-tauri/src/search/llm.rs @@ -337,13 +337,24 @@ fn transport_error( } /// Maps a [`crate::openai::OpenAiError`] from the `/v1` structured-output -/// client onto the search-pipeline error vocabulary, mirroring the -/// classification [`request_json`] applies on the native path. -fn map_openai_error(err: crate::openai::OpenAiError) -> SearchError { +/// client onto the search-pipeline error vocabulary. Unreachable and HTTP +/// failures route through the shared `/v1` classifiers in [`crate::openai`] +/// so the user-facing copy matches the active provider's flavor (builtin vs +/// remote); the remaining variants mirror the classification +/// [`request_json`] applies on the native path. +fn map_openai_error( + err: crate::openai::OpenAiError, + flavor: crate::openai::V1Flavor, + model: &str, +) -> SearchError { match err { crate::openai::OpenAiError::Cancelled => SearchError::Cancelled, - crate::openai::OpenAiError::Unreachable(_) => SearchError::LlmUnavailable, - crate::openai::OpenAiError::Http(status, _) => SearchError::LlmHttp(status), + crate::openai::OpenAiError::Unreachable(detail) => { + SearchError::Engine(crate::openai::v1_unreachable_error(&detail, flavor)) + } + crate::openai::OpenAiError::Http(status, _) => { + SearchError::Engine(crate::openai::classify_v1_http_error(status, model, flavor)) + } crate::openai::OpenAiError::BadBody(_) => SearchError::LlmBadJson, } } @@ -360,6 +371,7 @@ fn map_openai_error(err: crate::openai::OpenAiError) -> SearchError { async fn request_json_v1( base_url: &str, api_key: Option<&str>, + flavor: crate::openai::V1Flavor, model: &str, client: &reqwest::Client, messages: Vec, @@ -400,7 +412,7 @@ async fn request_json_v1( latency_ms: started.elapsed().as_millis() as u64, error, }); - result.map_err(map_openai_error) + result.map_err(|e| map_openai_error(e, flavor, model)) } /// Dispatches a structured-output request to the active transport: the @@ -437,10 +449,15 @@ async fn request_structured( ) .await } - LlmTransport::V1 { base_url, api_key } => { + LlmTransport::V1 { + base_url, + api_key, + flavor, + } => { request_json_v1( base_url, api_key.as_deref(), + *flavor, model, client, messages, @@ -472,8 +489,10 @@ async fn request_structured( /// /// # Errors /// - [`SearchError::Cancelled`] - token cancelled before or during the request. -/// - [`SearchError::LlmUnavailable`] - transport failure. -/// - [`SearchError::LlmHttp`] - non-2xx status from Ollama. +/// - [`SearchError::LlmUnavailable`] - transport failure (native path). +/// - [`SearchError::LlmHttp`] - non-2xx status from Ollama (native path). +/// - [`SearchError::Engine`] - transport or HTTP failure on the `/v1` path, +/// carrying flavor-aware copy from the shared classifiers. /// /// Note: this function retries once with a stricter user-message suffix when /// the first router response cannot be parsed. If the schema still cannot be @@ -653,8 +672,10 @@ fn parse_router_sufficiency(value: &str) -> Option { /// /// # Errors /// - [`SearchError::Cancelled`] - token cancelled before or during the request. -/// - [`SearchError::LlmUnavailable`] - transport failure. -/// - [`SearchError::LlmHttp`] - non-2xx status from Ollama. +/// - [`SearchError::LlmUnavailable`] - transport failure (native path). +/// - [`SearchError::LlmHttp`] - non-2xx status from Ollama (native path). +/// - [`SearchError::Engine`] - transport or HTTP failure on the `/v1` path, +/// carrying flavor-aware copy from the shared classifiers. /// /// Note: this function never returns [`SearchError::Judge`]. If the first /// attempt produces output that does not parse as [`JudgeVerdict`], we retry @@ -2409,6 +2430,7 @@ mod router_judge_tests { LlmTransport::V1 { base_url: base_url.into(), api_key: api_key.map(str::to_string), + flavor: crate::openai::V1Flavor::Remote, } } @@ -2510,7 +2532,7 @@ mod router_judge_tests { } #[tokio::test] - async fn v1_http_error_maps_to_llm_http() { + async fn v1_http_error_maps_to_flavored_engine_error() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/chat/completions")) @@ -2534,30 +2556,80 @@ mod router_judge_tests { ) .await .unwrap_err(); - assert_eq!(err, SearchError::LlmHttp(503)); + // The /v1 path classifies HTTP failures per flavor instead of the + // native path's "Ollama request failed" copy. + assert_eq!( + err, + SearchError::Engine(crate::openai::classify_v1_http_error( + 503, + "m", + crate::openai::V1Flavor::Remote, + )) + ); + assert!(!err.user_message().contains("Ollama")); } #[test] fn map_openai_error_covers_every_variant() { - use crate::openai::OpenAiError; + use crate::openai::{classify_v1_http_error, v1_unreachable_error, OpenAiError, V1Flavor}; assert_eq!( - map_openai_error(OpenAiError::Cancelled), + map_openai_error(OpenAiError::Cancelled, V1Flavor::Remote, "m"), SearchError::Cancelled ); + // Unreachable and HTTP failures route through the shared /v1 + // classifiers, so the copy is flavor-keyed instead of the fixed + // Ollama wording of the native path. assert_eq!( - map_openai_error(OpenAiError::Unreachable("refused".into())), - SearchError::LlmUnavailable + map_openai_error( + OpenAiError::Unreachable("refused".into()), + V1Flavor::Builtin, + "m" + ), + SearchError::Engine(v1_unreachable_error("refused", V1Flavor::Builtin)) ); assert_eq!( - map_openai_error(OpenAiError::Http(429, "slow down".into())), - SearchError::LlmHttp(429) + map_openai_error( + OpenAiError::Unreachable("refused".into()), + V1Flavor::Remote, + "m" + ), + SearchError::Engine(v1_unreachable_error("refused", V1Flavor::Remote)) ); assert_eq!( - map_openai_error(OpenAiError::BadBody("not json".into())), + map_openai_error( + OpenAiError::Http(404, "missing".into()), + V1Flavor::Builtin, + "m" + ), + SearchError::Engine(classify_v1_http_error(404, "m", V1Flavor::Builtin)) + ); + assert_eq!( + map_openai_error( + OpenAiError::BadBody("not json".into()), + V1Flavor::Remote, + "m" + ), SearchError::LlmBadJson ); } + /// End-to-end pin for the builtin flavor: an unreachable builtin engine + /// surfaces Thuki's own copy in chat, never the Ollama wording. The full + /// string is pinned: it is rendered verbatim by ErrorCard. + #[test] + fn map_openai_error_builtin_unreachable_user_message_names_thukis_engine() { + use crate::openai::{OpenAiError, V1Flavor}; + let err = map_openai_error( + OpenAiError::Unreachable("refused".into()), + V1Flavor::Builtin, + "m", + ); + assert_eq!( + err.user_message(), + "Thuki's engine isn't running\nSend your message again to restart it." + ); + } + /// The trace body recorded by `request_json_v1` must mirror the actual /// wire shape sent by `request_openai_json`: same keys, same structure, /// no hand-built approximations (e.g. the old non-wire key diff --git a/src-tauri/src/search/pipeline.rs b/src-tauri/src/search/pipeline.rs index a52f7104..6a557465 100644 --- a/src-tauri/src/search/pipeline.rs +++ b/src-tauri/src/search/pipeline.rs @@ -504,16 +504,18 @@ async fn run_streaming_branch( // num_ctx is NOT sent on /v1: for the builtin engine it is a launch // property of the llama-server process, and for openai-kind servers // it is informational only (spec 6.5). - LlmTransport::V1 { base_url, api_key } => { + LlmTransport::V1 { + base_url, + api_key, + flavor, + } => { crate::openai::stream_openai_chat( crate::openai::OpenAiChatParams { base_url: base_url.clone(), model: model.to_string(), messages, api_key: api_key.clone(), - // The transport collapses builtin into a generic /v1 - // server, so the neutral remote copy applies here. - flavor: crate::openai::V1Flavor::Remote, + flavor: *flavor, }, client, cancel_token, @@ -2937,6 +2939,7 @@ mod tests { let transport = LlmTransport::V1 { base_url: server.uri(), api_key: None, + flavor: crate::openai::V1Flavor::Remote, }; run_streaming_branch( diff --git a/src-tauri/src/search/types.rs b/src-tauri/src/search/types.rs index 3f2b7003..b8b78ffb 100644 --- a/src-tauri/src/search/types.rs +++ b/src-tauri/src/search/types.rs @@ -377,6 +377,12 @@ pub enum SearchError { LlmHttp(u16), /// Ollama returned content that could not be decoded as JSON. LlmBadJson, + /// A `/v1` provider call failed (unreachable server or non-2xx status). + /// Carries the [`crate::commands::EngineError`] composed by the shared + /// `/v1` classifiers in [`crate::openai`], so the copy matches the active + /// provider's flavor (builtin vs remote) and the search pipeline never + /// grows a second `/v1` copy table. + Engine(crate::commands::EngineError), /// Merged router+judge call failed: either no JSON was found in the /// response, or the JSON could not be deserialized as RouterJudgeOutput. /// The inner string carries diagnostic detail for logging; do not surface @@ -419,6 +425,7 @@ impl SearchError { Self::LlmBadJson => { "Search routing failed\nThe model returned an invalid response.".to_string() } + Self::Engine(e) => e.message.clone(), Self::Router(_) => { "Search routing failed\nThe model returned an invalid response.".to_string() } @@ -557,10 +564,27 @@ mod tests { #[test] fn error_messages_are_user_facing() { - assert!(SearchError::LlmUnavailable - .user_message() - .contains("Ollama isn't running")); - assert!(SearchError::LlmHttp(500).user_message().contains("500")); + // The native-path Ollama copy is pinned byte-for-byte so the + // flavor-aware /v1 work never drifts it. + assert_eq!( + SearchError::LlmUnavailable.user_message(), + "Ollama isn't running\nStart Ollama and try again." + ); + assert_eq!( + SearchError::LlmHttp(500).user_message(), + "Ollama request failed\nHTTP 500" + ); + // Engine carries copy already composed by the /v1 classifiers; + // user_message surfaces it verbatim. + assert_eq!( + SearchError::Engine(crate::commands::EngineError { + kind: crate::commands::EngineErrorKind::EngineUnreachable, + message: "Thuki's engine isn't running\nSend your message again to restart it." + .to_string(), + }) + .user_message(), + "Thuki's engine isn't running\nSend your message again to restart it." + ); assert!(SearchError::LlmBadJson .user_message() .contains("invalid response")); diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 4161f2f4..fb716616 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -744,7 +744,7 @@ describe('App', () => { } if (cmd === 'set_active_model') { rejectionSeen = true; - throw new Error('Model is not installed in Ollama: qwen2.5:7b'); + throw new Error('Model is not installed: qwen2.5:7b'); } return undefined; }); diff --git a/src/hooks/__tests__/useModelSelection.test.tsx b/src/hooks/__tests__/useModelSelection.test.tsx index 9e88b7ed..8f3a7a48 100644 --- a/src/hooks/__tests__/useModelSelection.test.tsx +++ b/src/hooks/__tests__/useModelSelection.test.tsx @@ -208,9 +208,7 @@ describe('useModelSelection', () => { all: ['gemma4:e2b', 'qwen2.5:7b'], ollamaReachable: true, }) - .mockRejectedValueOnce( - new Error('Model is not installed in Ollama: mystery'), - ); + .mockRejectedValueOnce(new Error('Model is not installed: mystery')); const { result } = renderHook(() => useModelSelection()); await act(async () => {}); @@ -219,7 +217,7 @@ describe('useModelSelection', () => { act(async () => { await result.current.setActiveModel('mystery'); }), - ).rejects.toThrow('Model is not installed in Ollama: mystery'); + ).rejects.toThrow('Model is not installed: mystery'); expect(result.current.activeModel).toBe('gemma4:e2b'); }); From fa065cbb0bd6ab438cb6a27d02686d834908e80d Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 12 Jun 2026 10:35:50 -0400 Subject: [PATCH 07/51] feat: add installed model management and license notices Signed-off-by: Logan Nguyen --- src-tauri/src/models/registry.rs | 9 + .../__tests__/StarterPicker.test.tsx | 22 +++ src/settings/tabs/ProviderCards.test.tsx | 158 +++++++++++++++++- src/settings/tabs/ProviderCards.tsx | 68 ++++++++ src/types/starter.ts | 2 + 5 files changed, 257 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/models/registry.rs b/src-tauri/src/models/registry.rs index 2c9e79c1..c105a153 100644 --- a/src-tauri/src/models/registry.rs +++ b/src-tauri/src/models/registry.rs @@ -247,6 +247,15 @@ mod tests { } } + #[test] + fn license_notes_per_tier() { + // The picker surfaces these verbatim: both Gemma tiers carry the + // Gemma Terms of Use, the Phi-4 tier is MIT. + assert_eq!(starter(Tier::Fast).license_note, "Gemma Terms of Use"); + assert_eq!(starter(Tier::Balanced).license_note, "Gemma Terms of Use"); + assert_eq!(starter(Tier::Smartest).license_note, "MIT"); + } + #[test] fn mmproj_hashes_are_distinct_between_gemma_tiers() { let fast = starter(Tier::Fast); diff --git a/src/components/__tests__/StarterPicker.test.tsx b/src/components/__tests__/StarterPicker.test.tsx index 0f82ab2b..92bd785a 100644 --- a/src/components/__tests__/StarterPicker.test.tsx +++ b/src/components/__tests__/StarterPicker.test.tsx @@ -127,6 +127,28 @@ describe('StarterPicker', () => { expect(onSelect).not.toHaveBeenCalled(); }); + it('renders the per-tier license notes: two Gemma Terms and one MIT', () => { + // Mirrors the backend registry: both Gemma tiers carry the Gemma Terms + // of Use; the Phi-4 tier is MIT. Each card links out via open_url. + renderPicker([ + makeOption('fast', {}, { license_note: 'Gemma Terms of Use' }), + makeOption('balanced', {}, { license_note: 'Gemma Terms of Use' }), + makeOption('smartest', {}, { license_note: 'MIT' }), + ]); + expect(screen.getAllByText('Gemma Terms of Use')).toHaveLength(2); + expect(screen.getByText('MIT')).toBeInTheDocument(); + for (const tier of ['fast', 'balanced', 'smartest']) { + fireEvent.click( + screen.getByRole('button', { + name: `Open Model ${tier} on Hugging Face`, + }), + ); + expect(invoke).toHaveBeenCalledWith('open_url', { + url: `https://huggingface.co/org/${tier}-repo`, + }); + } + }); + it('marks the selected tier card', () => { const { container } = renderPicker(THREE_TIERS); const cards = container.querySelectorAll('[data-starter-card]'); diff --git a/src/settings/tabs/ProviderCards.test.tsx b/src/settings/tabs/ProviderCards.test.tsx index 10dc2dfb..0d6b2529 100644 --- a/src/settings/tabs/ProviderCards.test.tsx +++ b/src/settings/tabs/ProviderCards.test.tsx @@ -110,8 +110,18 @@ function makeConfig(builtinModel: string): RawAppConfig { } const INSTALLED: InstalledModel[] = [ - { id: 'org/gemma:gemma.gguf', display_name: 'gemma', quant: 'Q4_K_M' }, - { id: 'org/qwen:qwen.gguf', display_name: 'qwen', quant: '' }, + { + id: 'org/gemma:gemma.gguf', + display_name: 'gemma', + size_bytes: 2_489_757_856, + quant: 'Q4_K_M', + }, + { + id: 'org/qwen:qwen.gguf', + display_name: 'qwen', + size_bytes: 9_000_000_000, + quant: '', + }, ]; const STARTER_OPTION: StarterOption = { @@ -175,6 +185,17 @@ function StatefulOpenAiCard() { ); } +/** + * Wraps the builtin card the way ModelTab does: `onSaved` lifts the returned + * config snapshot so a backend-side model clear reaches the dropdown. + */ +function StatefulBuiltinCard({ initialModel }: { initialModel: string }) { + const [config, setConfig] = useState(() => + makeConfig(initialModel), + ); + return ; +} + type MockChannel = { simulateMessage: (msg: unknown) => void }; /** Marks a command response as a rejection in `mockCommands`. */ @@ -545,6 +566,139 @@ describe('BuiltinProviderCard', () => { await flush(); expect(screen.getByRole('alert')).toHaveTextContent('repo not found'); }); + + it('lists each installed model with size, quant, and a delete affordance', async () => { + mockCommands(builtinResponses()); + await renderCard(); + expect(screen.getByText('gemma · 2.5 GB · Q4_K_M')).toBeInTheDocument(); + // Empty quant omits the trailing separator. + expect(screen.getByText('qwen · 9.0 GB')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Delete gemma' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Delete qwen' }), + ).toBeInTheDocument(); + }); + + it('delete asks for confirmation and Cancel backs out without deleting', async () => { + mockCommands(builtinResponses()); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Delete gemma' })); + expect( + screen.getByText('Delete gemma? Its files are removed from disk.'), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect( + screen.queryByText('Delete gemma? Its files are removed from disk.'), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Delete gemma' }), + ).toBeInTheDocument(); + expect(invokeMock).not.toHaveBeenCalledWith( + 'delete_installed_model', + expect.anything(), + ); + }); + + it('confirmed delete invokes delete_installed_model and refreshes the rows', async () => { + let deleted = false; + mockCommands( + builtinResponses({ + list_installed_models: () => (deleted ? [INSTALLED[1]] : INSTALLED), + delete_installed_model: () => { + deleted = true; + return undefined; + }, + }), + ); + const onSaved = vi.fn(); + await renderCard('', onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Delete gemma' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('delete_installed_model', { + id: 'org/gemma:gemma.gguf', + }); + expect( + screen.queryByText('gemma · 2.5 GB · Q4_K_M'), + ).not.toBeInTheDocument(); + expect(screen.getByText('qwen · 9.0 GB')).toBeInTheDocument(); + // The deletion also re-fetches the starter rows (an installed starter + // flips back to downloadable) and lifts the fresh config snapshot. + expect(invokeMock).toHaveBeenCalledWith('get_starter_options'); + expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG); + }); + + it('deleting the active model clears the selection and shows the picker affordance', async () => { + let deleted = false; + mockCommands( + builtinResponses({ + list_installed_models: () => (deleted ? [INSTALLED[1]] : INSTALLED), + delete_installed_model: () => { + deleted = true; + return undefined; + }, + // The backend cleared the builtin provider's model field itself. + get_config: () => makeConfig(''), + }), + ); + render(); + await flush(); + const select = screen.getByRole('combobox', { + name: 'Built-in model', + }) as HTMLSelectElement; + expect(select.value).toBe('org/gemma:gemma.gguf'); + fireEvent.click(screen.getByRole('button', { name: 'Delete gemma' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await flush(); + expect(select.value).toBe(''); + expect(screen.getByText('Choose a model')).toBeInTheDocument(); + }); + + it('surfaces a delete failure and keeps the row', async () => { + mockCommands( + builtinResponses({ + delete_installed_model: new Reject('file busy'), + }), + ); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Delete gemma' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await flush(); + expect(screen.getByRole('alert')).toHaveTextContent('file busy'); + expect(screen.getByText('gemma · 2.5 GB · Q4_K_M')).toBeInTheDocument(); + expect(invokeMock).not.toHaveBeenCalledWith('get_config'); + // A later successful delete clears the stale error. + mockCommands( + builtinResponses({ + list_installed_models: [INSTALLED[1]], + delete_installed_model: undefined, + }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Delete gemma' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await flush(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('leaves the lift to the focus resync when get_config fails post-delete', async () => { + mockCommands( + builtinResponses({ + delete_installed_model: undefined, + get_config: new Reject(new Error('read failed')), + }), + ); + const onSaved = vi.fn(); + await renderCard('', onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Delete qwen' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await flush(); + expect(invokeMock).toHaveBeenCalledWith('delete_installed_model', { + id: 'org/qwen:qwen.gguf', + }); + expect(onSaved).not.toHaveBeenCalled(); + }); }); // ─── OpenAiProviderCard ────────────────────────────────────────────────────── diff --git a/src/settings/tabs/ProviderCards.tsx b/src/settings/tabs/ProviderCards.tsx index a21320b9..9e502fb3 100644 --- a/src/settings/tabs/ProviderCards.tsx +++ b/src/settings/tabs/ProviderCards.tsx @@ -66,6 +66,8 @@ export function BuiltinProviderCard({ config.inference.providers.find((p) => p.kind === 'builtin')?.model ?? ''; const [installed, setInstalled] = useState([]); + const [confirmingDelete, setConfirmingDelete] = useState(null); + const [deleteError, setDeleteError] = useState(null); const [downloadOpen, setDownloadOpen] = useState(false); const [selected, setSelected] = useState('balanced'); const [freeDiskBytes, setFreeDiskBytes] = useState(null); @@ -152,6 +154,28 @@ export function BuiltinProviderCard({ }); } + // Deletion is refcounted server-side (shared blobs survive); the backend + // also clears the builtin provider's model field when the deleted model + // was the selected one, so the lifted snapshot is the source of truth. + async function handleDelete(id: string) { + setConfirmingDelete(null); + try { + await invoke('delete_installed_model', { id }); + } catch (err) { + setDeleteError(String(err)); + return; + } + setDeleteError(null); + // A deleted starter flips back to downloadable in the picker rows. + await refresh(); + await refreshInstalled(); + try { + onSaved(await invoke('get_config')); + } catch { + // The focus-driven resync picks the change up on next activation. + } + } + async function handleLookup() { setRepoError(null); setRepoFiles(null); @@ -205,6 +229,50 @@ export function BuiltinProviderCard({ )} + {installed.map((m) => ( +
+ + {m.display_name} · {gb(m.size_bytes)} GB + {m.quant !== '' ? ` · ${m.quant}` : ''} + + {confirmingDelete === m.id ? ( + <> + + Delete {m.display_name}? Its files are removed from disk. + + + + + ) : ( + + )} +
+ ))} + {deleteError !== null ? ( +

+ {deleteError} +

+ ) : null} + - + {compact ? 'Browse' : 'Browse Ollama'} + + + + )}
- No models installed. Run{' '} - ollama pull <model>{' '} - in your terminal, then come back. + {providerKind === 'builtin' ? ( + BUILTIN_NO_MODELS_MESSAGE + ) : providerKind === 'openai' ? ( + OPENAI_NO_MODEL_MESSAGE + ) : ( + <> + No models installed. Run{' '} + + ollama pull <model> + {' '} + in your terminal, then come back. + + )}

) : filtered.length === 0 ? (

diff --git a/src/components/__tests__/DownloadProgress.test.tsx b/src/components/__tests__/DownloadProgress.test.tsx index 4c2b88d8..6e9768f1 100644 --- a/src/components/__tests__/DownloadProgress.test.tsx +++ b/src/components/__tests__/DownloadProgress.test.tsx @@ -279,5 +279,28 @@ describe('DownloadProgress', () => { ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); }); + + it('renders Choose a different model when onChooseAnother is wired', () => { + const onChooseAnother = vi.fn(); + renderProgress( + { phase: 'failed', kind: 'disk_full', message: 'no space left' }, + { onChooseAnother }, + ); + fireEvent.click( + screen.getByRole('button', { name: 'Choose a different model' }), + ); + expect(onChooseAnother).toHaveBeenCalledTimes(1); + }); + + it('omits Choose a different model when onChooseAnother is absent', () => { + renderProgress({ + phase: 'failed', + kind: 'disk_full', + message: 'no space left', + }); + expect( + screen.queryByRole('button', { name: 'Choose a different model' }), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/components/__tests__/ModelPickerPanel.test.tsx b/src/components/__tests__/ModelPickerPanel.test.tsx index 6eae257f..69d7cee0 100644 --- a/src/components/__tests__/ModelPickerPanel.test.tsx +++ b/src/components/__tests__/ModelPickerPanel.test.tsx @@ -7,6 +7,10 @@ import { OLLAMA_PILL_TOOLTIP, } from '../ModelPickerPanel'; import type { ModelCapabilitiesMap } from '../../types/model'; +import { + BUILTIN_NO_MODELS_MESSAGE, + OPENAI_NO_MODEL_MESSAGE, +} from '../../utils/capabilityConflicts'; import { invoke } from '@tauri-apps/api/core'; vi.mock('@tauri-apps/api/core', () => ({ @@ -117,6 +121,31 @@ describe('ModelPickerPanel', () => { expect(screen.queryByRole('option')).toBeNull(); }); + it('routes a builtin user to the Settings download picker in the empty state', () => { + renderPanel({ models: [], providerKind: 'builtin' }); + const empty = screen.getByTestId('model-picker-empty'); + expect(empty.textContent).toBe(BUILTIN_NO_MODELS_MESSAGE); + expect(empty.textContent).not.toContain('ollama pull'); + }); + + it('routes an openai user to the Settings provider model in the empty state', () => { + renderPanel({ models: [], providerKind: 'openai' }); + const empty = screen.getByTestId('model-picker-empty'); + expect(empty.textContent).toBe(OPENAI_NO_MODEL_MESSAGE); + expect(empty.textContent).not.toContain('ollama pull'); + }); + + it('keeps the ollama-pull empty state when providerKind is ollama', () => { + renderPanel({ models: [], providerKind: 'ollama' }); + const empty = screen.getByTestId('model-picker-empty'); + expect(empty.textContent).toContain('ollama pull '); + }); + + it('hides the Browse Ollama pill for non-ollama providers', () => { + renderPanel({ providerKind: 'builtin' }); + expect(screen.queryByTestId('model-picker-ollama-link')).toBeNull(); + }); + it('renders no row as active when activeModel is null', () => { // S2/S3: the chip stays clickable with a null active model. The panel // must accept null without inventing a default and simply mark no row diff --git a/src/hooks/__tests__/useDownloadModel.test.tsx b/src/hooks/__tests__/useDownloadModel.test.tsx index a055b85b..fef7aaa9 100644 --- a/src/hooks/__tests__/useDownloadModel.test.tsx +++ b/src/hooks/__tests__/useDownloadModel.test.tsx @@ -314,6 +314,48 @@ describe('useDownloadModel', () => { }); }); + it('reset returns failed to idle and clears the stale progress', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('smartest')); + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'w.gguf', total_bytes: 100, resumed_from: 40 }, + }), + ); + act(() => + channel().simulateMessage({ + type: 'Failed', + data: { kind: 'disk_full', message: 'no space left' }, + }), + ); + expect(result.current.progress?.bytes).toBe(40); + + act(() => result.current.reset()); + expect(result.current.state).toEqual({ phase: 'idle' }); + expect(result.current.progress).toBeNull(); + expect(result.current.etaSeconds).toBeNull(); + }); + + it('reset returns ready to idle', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('fast')); + act(() => channel().simulateMessage({ type: 'AllDone' })); + expect(result.current.state).toEqual({ phase: 'ready' }); + + act(() => result.current.reset()); + expect(result.current.state).toEqual({ phase: 'idle' }); + }); + + it('reset is a no-op outside the terminal phases', async () => { + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('fast')); + expect(result.current.state).toEqual({ phase: 'downloading' }); + + act(() => result.current.reset()); + expect(result.current.state).toEqual({ phase: 'downloading' }); + }); + it('resumes through the same start call', async () => { const { result } = renderHook(() => useDownloadModel()); act(() => result.current.enterResumePending()); diff --git a/src/hooks/useDownloadModel.ts b/src/hooks/useDownloadModel.ts index 2728d42a..b30448d5 100644 --- a/src/hooks/useDownloadModel.ts +++ b/src/hooks/useDownloadModel.ts @@ -15,7 +15,9 @@ * * The backend emits `AllDone` only after the install is recorded; a finalize * failure (the manifest write failed) emits `Failed` instead of `AllDone`. - * `Failed` is terminal from any state. + * `Failed` is terminal from any state. Terminal means no *event* moves the + * machine out of it; the user can still leave through `reset`, an explicit + * action that returns the terminal `failed`/`ready` cards to the picker. */ import { useCallback, useEffect, useRef, useState } from 'react'; @@ -113,6 +115,13 @@ export interface UseDownloadModel { discard: (sha256: string) => Promise; /** Caller sets this when starter options show partial_bytes. */ enterResumePending: () => void; + /** + * failed -> idle and ready -> idle; no-op in every other phase. A user + * action, not an event transition, so the terminal-Failed contract is + * intact: no backend event ever leaves `failed`, but the user may step + * back to the picker to choose a different model. + */ + reset: () => void; } export interface UseDownloadModelOptions { @@ -303,6 +312,18 @@ export function useDownloadModel( setState({ phase: 'resume_pending' }); }, []); + const reset = useCallback(() => { + setState((prev) => + prev.phase === 'failed' || prev.phase === 'ready' + ? { phase: 'idle' } + : prev, + ); + // Stale byte counts from the run that just ended; the next start + // reseeds them. Callers only invoke reset from the terminal cards. + setProgress(null); + setEtaSeconds(null); + }, []); + return { state, progress, @@ -316,5 +337,6 @@ export function useDownloadModel( resume: start, discard, enterResumePending, + reset, }; } diff --git a/src/settings/configHelpers.ts b/src/settings/configHelpers.ts index bd0dc2e7..32e87aca 100644 --- a/src/settings/configHelpers.ts +++ b/src/settings/configHelpers.ts @@ -29,7 +29,7 @@ const HELPERS = { openai_vision: 'Whether the selected model accepts image inputs. OpenAI-compatible servers expose no capability probe, so you declare it yourself. Turn it on only if the model truly supports images; otherwise requests with attachments will fail.', num_ctx: - "The size of the context window sent to Ollama with every request, in tokens. This value must match between warmup and chat so Ollama can reuse the same runner and its cached key-value prefix for the system prompt. Raise to fit longer conversations without the model forgetting early messages; lower to reduce GPU memory use. Ollama caps the effective value at the model's trained maximum, so anything beyond that is silently clamped, not used. Valid range: 2048–1048576. The default (16384) comfortably fits the system prompt plus several long turns.", + "The size of the context window in tokens, applied to whichever provider is active. For the built-in engine the value becomes --ctx-size when llama-server starts, so changing it restarts the engine (a few seconds). For Ollama it is sent with every request, shared between warmup and chat so the same runner and its cached system-prompt prefix are reused, and silently capped at the model's trained maximum. For OpenAI-compatible servers it is informational only; the server controls the actual context. Raise to fit longer conversations without the model forgetting early messages; lower to reduce memory use. Valid range: 2048–1048576. The default (16384) comfortably fits the system prompt plus several long turns.", }, prompt: { system: diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index 79320f63..22f225b8 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -134,22 +134,17 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { const { activeModel, availableModels, setActiveModel } = useModelSelection(); useEffect(() => { - let unlistenLoaded: (() => void) | null = null; - let unlistenEvicted: (() => void) | null = null; - - async function setup() { - unlistenLoaded = await listen('warmup:model-loaded', (e) => { - setLoadedModel(e.payload); - }); - unlistenEvicted = await listen('warmup:model-evicted', () => { - setLoadedModel(null); - }); - invoke('get_loaded_model') - .then(setLoadedModel) - .catch(() => {}); - } - - setup(); + // Cleanup chains on the listen promises (not a captured variable) so an + // unmount that races the registration still detaches every listener. + const unlistenLoaded = listen('warmup:model-loaded', (e) => { + setLoadedModel(e.payload); + }); + const unlistenEvicted = listen('warmup:model-evicted', () => { + setLoadedModel(null); + }); + invoke('get_loaded_model') + .then(setLoadedModel) + .catch(() => {}); function handleVisibilityChange() { if (!document.hidden) { @@ -161,21 +156,27 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { document.addEventListener('visibilitychange', handleVisibilityChange); return () => { - unlistenLoaded?.(); - unlistenEvicted?.(); + void unlistenLoaded.then((unlisten) => unlisten()); + void unlistenEvicted.then((unlisten) => unlisten()); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); useEffect(() => { - let unlisten: (() => void) | null = null; - void listen('engine:status', (e) => { + // Seed from the runner's current snapshot: the backend only emits + // engine:status on transitions, so without this an already-loaded + // engine would read "stopped" (and Unload now would stay dead) until + // the next transition. + invoke('get_engine_status') + .then((status) => setEngineState(status.state)) + .catch(() => { + // Keep the stopped default; the event stream corrects it. + }); + const unlistenPromise = listen('engine:status', (e) => { setEngineState(e.payload.state); - }).then((fn) => { - unlisten = fn; }); return () => { - unlisten?.(); + void unlistenPromise.then((unlisten) => unlisten()); }; }, []); @@ -656,7 +657,11 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) {

~{ctxTurns.toLocaleString()} turns of context {' · '} - Ollama caps to your model's trained maximum. + {activeKind === 'builtin' + ? 'Passed to the engine as --ctx-size at start; changing it restarts the engine.' + : activeKind === 'openai' + ? 'Informational only; your server controls the actual context.' + : "Ollama caps to your model's trained maximum."}
diff --git a/src/settings/tabs/ProviderCards.test.tsx b/src/settings/tabs/ProviderCards.test.tsx index 0d6b2529..b8744c8d 100644 --- a/src/settings/tabs/ProviderCards.test.tsx +++ b/src/settings/tabs/ProviderCards.test.tsx @@ -393,6 +393,65 @@ describe('BuiltinProviderCard', () => { await waitFor(() => expect(onSaved).toHaveBeenCalledWith(NEW_CONFIG)); }); + it('returns to the picker once the Ready card dwell elapses', async () => { + vi.useFakeTimers(); + try { + mockCommands(builtinResponses()); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Download' })[1]); + await flush(); + act(() => { + lastChannel?.simulateMessage({ type: 'AllDone' }); + }); + await flush(); + // Success card up, starter rows hidden. + expect(screen.getByText('Ready')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Download' }), + ).not.toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(2500); + }); + expect(screen.queryByText('Ready')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Download' }), + ).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('Choose a different model on the failed card returns to the picker', async () => { + mockCommands(builtinResponses()); + await renderCard(); + fireEvent.click(screen.getByRole('button', { name: 'Download a model' })); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Download' })[1]); + await flush(); + act(() => { + lastChannel?.simulateMessage({ + type: 'Failed', + data: { kind: 'disk_full', message: 'no space left' }, + }); + }); + expect( + screen.getByText('Not enough disk space. Free up space and retry.'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Download' }), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Choose a different model' }), + ); + expect( + screen.getByRole('button', { name: 'Download' }), + ).toBeInTheDocument(); + }); + it('leaves the lift to the focus resync when get_config fails post-download', async () => { mockCommands( builtinResponses({ get_config: new Reject(new Error('read failed')) }), diff --git a/src/settings/tabs/ProviderCards.tsx b/src/settings/tabs/ProviderCards.tsx index 9e502fb3..12693e1e 100644 --- a/src/settings/tabs/ProviderCards.tsx +++ b/src/settings/tabs/ProviderCards.tsx @@ -41,6 +41,13 @@ function gb(bytes: number): string { return (bytes / 1e9).toFixed(1); } +/** + * How long the post-download "Ready" card stays up before the kit returns + * to the picker. Long enough to read, short enough to need no dismiss + * affordance; mirrors the eject button's 2.5 s confirmation in ModelTab. + */ +const READY_CARD_DWELL_MS = 2500; + /** Shared remote-URL caution, same mechanism as the Ollama URL warning. */ function NonLocalWarning() { return ( @@ -92,6 +99,7 @@ export function BuiltinProviderCard({ resume, discard, enterResumePending, + reset, } = useDownloadModel(); const refreshInstalled = useCallback(async () => { @@ -129,6 +137,9 @@ export function BuiltinProviderCard({ // Download finished: the backend already wrote the builtin provider's // model field, so refresh the rows and lift the new config snapshot. + // After a short dwell the Ready card has served its purpose; reset to + // idle so the starter rows (now marked Installed) come back without a + // tab remount. useEffect(() => { if (state.phase !== 'ready') return; void (async () => { @@ -140,7 +151,9 @@ export function BuiltinProviderCard({ // The focus-driven resync picks the change up on next activation. } })(); - }, [state.phase, refresh, refreshInstalled, onSaved]); + const timer = window.setTimeout(reset, READY_CARD_DWELL_MS); + return () => window.clearTimeout(timer); + }, [state.phase, refresh, refreshInstalled, onSaved, reset]); function commitModel(id: string) { void invoke('update_provider_field', { @@ -311,6 +324,9 @@ export function BuiltinProviderCard({ onCancelConfirm={cancelConfirm} onCancel={() => void cancel()} onRetry={() => void retry()} + // Same trap-avoidance as onboarding: a terminal failure must + // leave a path back to the starter rows, not just Retry. + onChooseAnother={reset} />
diff --git a/src/settings/tabs/tabs.test.tsx b/src/settings/tabs/tabs.test.tsx index e2fe51fc..600fe015 100644 --- a/src/settings/tabs/tabs.test.tsx +++ b/src/settings/tabs/tabs.test.tsx @@ -19,6 +19,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; import { clearEventHandlers, emitTauriEvent, @@ -130,6 +131,9 @@ beforeEach(() => { invokeMock.mockReset(); invokeMock.mockImplementation((cmd: string) => { if (cmd === 'get_loaded_model') return Promise.resolve(null); + if (cmd === 'get_engine_status') { + return Promise.resolve(engineStatus('stopped')); + } if (cmd === 'get_model_picker_state') { return Promise.resolve({ active: null, all: [], ollamaReachable: false }); } @@ -1289,6 +1293,118 @@ describe('ModelTab', () => { }); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); + + // ─── Engine status mount seeding + listener cleanup ───────────────────── + + it('seeds the residency line from get_engine_status on mount', async () => { + // The backend emits engine:status only on transitions; an engine that + // is already loaded must be reflected (and Unload now enabled) without + // waiting for the next event. + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'get_engine_status') { + return Promise.resolve(engineStatus('loaded')); + } + if (cmd === 'get_loaded_model') return Promise.resolve(null); + if (cmd === 'get_model_picker_state') { + return Promise.resolve({ + active: null, + all: [], + ollamaReachable: false, + }); + } + return Promise.resolve(CONFIG); + }); + await renderBuiltinActive(); + expect(screen.getByText('Engine: loaded')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unload now' })).toBeEnabled(); + }); + + it('keeps the stopped default when the get_engine_status seed rejects', async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === 'get_engine_status') { + return Promise.reject(new Error('runner not managed')); + } + if (cmd === 'get_loaded_model') return Promise.resolve(null); + if (cmd === 'get_model_picker_state') { + return Promise.resolve({ + active: null, + all: [], + ollamaReachable: false, + }); + } + return Promise.resolve(CONFIG); + }); + await renderBuiltinActive(); + expect(screen.getByText('Engine: stopped')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unload now' })).toBeDisabled(); + }); + + it('detaches every listener even when unmount races the listen promise', async () => { + // Regression for the leak where cleanup ran before listen() resolved + // and the captured unlisten was still null, leaving the handler + // registered forever. The promise-chained cleanup must detach all of + // them once the registrations resolve. + const listenMock = listen as unknown as ReturnType; + const original = listenMock.getMockImplementation(); + let removed = 0; + listenMock.mockImplementation(async () => () => { + removed += 1; + }); + try { + const before = listenMock.mock.calls.length; + const view = render( + {}} + />, + ); + const registered = listenMock.mock.calls.length - before; + expect(registered).toBe(3); // engine:status + the warmup pair + // Unmount before the listen promises are flushed. + view.unmount(); + await act(async () => { + await Promise.resolve(); + }); + expect(removed).toBe(registered); + } finally { + listenMock.mockImplementation(original!); + } + }); + + // ─── Context Window helper copy per provider kind ──────────────────────── + + it('shows the builtin ctx helper while the built-in provider is active', async () => { + await renderBuiltinActive(); + expect( + screen.getByText(/--ctx-size at start; changing it restarts the engine/), + ).toBeInTheDocument(); + expect(screen.queryByText(/Ollama caps/)).not.toBeInTheDocument(); + }); + + it('shows the server-controlled ctx helper for an openai provider', async () => { + const cfg: RawAppConfig = { + ...OPENAI_CONFIG, + inference: { ...OPENAI_CONFIG.inference, active_provider: 'openai' }, + }; + render( {}} />); + await act(async () => { + await Promise.resolve(); + }); + expect( + screen.getByText( + /Informational only; your server controls the actual context/, + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/Ollama caps/)).not.toBeInTheDocument(); + }); + + it('keeps the Ollama ctx helper for the ollama provider', async () => { + await renderModelTab(); + expect( + screen.getByText(/Ollama caps to your model's trained maximum\./), + ).toBeInTheDocument(); + }); }); describe('DisplayTab', () => { diff --git a/src/view/onboarding/ModelCheckStep.tsx b/src/view/onboarding/ModelCheckStep.tsx index 7282b57b..b54c6b7c 100644 --- a/src/view/onboarding/ModelCheckStep.tsx +++ b/src/view/onboarding/ModelCheckStep.tsx @@ -203,6 +203,7 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { resume, discard, enterResumePending, + reset, } = useDownloadModel(); const [selected, setSelected] = useState('balanced'); const [ollamaDetected, setOllamaDetected] = useState(false); @@ -322,6 +323,10 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { onCancelConfirm={cancelConfirm} onCancel={() => void cancel()} onRetry={() => void retry()} + // A failed download (disk full, checksum) must not trap the + // user on Retry: this steps back to the picker so a smaller + // tier stays reachable. + onChooseAnother={reset} /> {hatchBesideProgress ? ( + ) : null} +
+ ); +} + +/** Left axis: the row labels, height-matched to the tier columns. */ +function LabelColumn() { + const cell = (label: string) => ( +
+ {label} +
+ ); + return ( +
+
+ {cell('Speed')} + {cell('Quality')} + {cell('Vision')} + {cell('On your Mac')} + {cell('License')} +
+ ); +} + +interface TierColumnProps { + option: StarterOption; + recommended: boolean; + active: boolean; + dimmed: boolean; + disabled: boolean; + state: DownloadUiState; + progress: DownloadProgressInfo | null; + etaSeconds: number | null; + onDownload: (tier: StarterTier) => void; + onResume: ( + tier: StarterTier, + partialBytes: number, + sizeBytes: number, + ) => void; + onDiscard: (sha256: string) => void; + onCancel: () => void; + onRetry: () => void; +} + +function TierColumn({ + option, + recommended, + active, + dimmed, + disabled, + state, + progress, + etaSeconds, + onDownload, + onResume, + onDiscard, + onCancel, + onRetry, +}: TierColumnProps) { + const { starter, fit } = option; + const levels = TIER_LEVELS[starter.tier]; + const fitInfo = FIT_SHORT[fit]; + + return ( +
+ {/* Header: tier eyebrow, then name + size on one line */} +
+
+ {TIER_LABELS[starter.tier]} + {recommended ? ' ★' : ''} +
+
+ + {starter.display_name} + + + {gb(totalBytes(option))} GB + +
+
+ + + + + + {starter.vision ? ( + Yes + ) : ( + + )} + + + + + {fitInfo.label} + + + + + + + + {/* Action: the filling download cell when this column is active, + otherwise the plain download/resume/installed affordance. Fixed + height so the optional Discard link never shifts the layout. */} +
+ {active ? ( + + ) : ( + + )} +
+
+ ); +} + +/** A trait row holding a horizontal level bar. */ +function BarCell({ level }: { level: number }) { + return ( +
+
+
+
+
+ ); +} + +/** A trait row holding a short text value (Vision, On your Mac, License). */ +function ValueCell({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +interface DownloadCellProps { + state: DownloadUiState; + progress: DownloadProgressInfo | null; + etaSeconds: number | null; + onCancel: () => void; + onRetry: () => void; +} + +/** + * The active column's download display: the pressed button morphs into a + * filling progress bar, counting up while determinate and showing the + * post-download steps (verify, install, ready) as a full bar with a label. + * A failure swaps in a short headline plus Retry. + */ +function DownloadCell({ + state, + progress, + etaSeconds, + onCancel, + onRetry, +}: DownloadCellProps) { + const [hover, setHover] = useState(false); + + if (state.phase === 'failed') { + return ( +
+
+ {FAIL_SHORT[state.kind]} +
+ +
+ ); + } + + // While bytes are coming down, the button IS the progress: it fills as it + // downloads, shows the byte counts + ETA inside (no percentage), and is the + // cancel control. Hovering eases the warm fill to a neutral "stop" grey and + // swaps in "Cancel download", so a click clearly stops it. + if (state.phase === 'downloading' || state.phase === 'downloading_mmproj') { + const pct = + progress && progress.totalBytes > 0 + ? Math.floor((progress.bytes / progress.totalBytes) * 100) + : 0; + const bytesLabel = progress + ? `${gb(progress.bytes)} / ${gb(progress.totalBytes)} GB${ + etaSeconds !== null ? ` · ${formatEta(etaSeconds)} left` : '' + }` + : 'Starting…'; + return ( + + ); + } + + // Verifying / installing / warming / ready: a full bar with a label. The + // bytes are already down, so there is nothing left to cancel. + const ready = state.phase === 'ready'; + const label = + state.phase === 'verifying' + ? 'Verifying' + : state.phase === 'installing' + ? 'Installing' + : state.phase === 'ready' + ? 'Ready' + : 'Starting engine'; + return ( +
+
+ ); +} + +interface ColumnActionProps { + option: StarterOption; + recommended: boolean; + disabled: boolean; + onDownload: (tier: StarterTier) => void; + onResume: ( + tier: StarterTier, + partialBytes: number, + sizeBytes: number, + ) => void; + onDiscard: (sha256: string) => void; +} + +/** + * Per-column affordance: an installed line, a resume/discard pair when an + * interrupted partial exists, or the plain download button (primary gradient + * on the recommended column, quiet outline otherwise). `disabled` dims the + * buttons while another column's download is in flight. + */ +function ColumnAction({ + option, + recommended, + disabled, + onDownload, + onResume, + onDiscard, +}: ColumnActionProps) { + const { starter, installed, partial_bytes } = option; + + if (installed) { + return ( +
+ Installed +
+ ); + } + + if (partial_bytes !== null) { + return ( +
+ + {!disabled ? ( + onDiscard(starter.sha256)} /> + ) : null} +
+ ); + } + + return ( + onDownload(starter.tier)} + /> + ); +} + +interface ActionButtonProps { + label: string; + recommended: boolean; + disabled?: boolean; + onClick: () => void; +} + +function ActionButton({ + label, + recommended, + disabled = false, + onClick, +}: ActionButtonProps) { + const [hover, setHover] = useState(false); + const showHover = hover && !disabled; + return ( + + ); +} + +interface ResumeButtonProps { + tier: StarterTier; + /** Weights total; the caller has already narrowed partialBytes to non-null. */ + sizeBytes: number; + partialBytes: number; + disabled: boolean; + onResume: ( + tier: StarterTier, + partialBytes: number, + sizeBytes: number, + ) => void; +} + +/** + * Resume affordance for an interrupted partial. The mirror of the downloading + * button: at rest it shows how far the download got ("2.1 / 2.5 GB") behind a + * dimmed warm fill; hovering brings the fill to full strength and swaps in + * "Resume". Both shifts are smooth (opacity tweens, no gradient swap). + */ +function ResumeButton({ + tier, + sizeBytes, + partialBytes, + disabled, + onResume, +}: ResumeButtonProps) { + const [hover, setHover] = useState(false); + const pct = Math.min(100, Math.floor((partialBytes / sizeBytes) * 100)); + const bytesLabel = `${gb(partialBytes)} / ${gb(sizeBytes)} GB`; + const showHover = hover && !disabled; + return ( + + ); +} + +/** The quiet grey "Discard partial" link beneath a Resume button. */ +function DiscardLink({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/src/components/__tests__/StarterMatrix.test.tsx b/src/components/__tests__/StarterMatrix.test.tsx new file mode 100644 index 00000000..a5bd22a4 --- /dev/null +++ b/src/components/__tests__/StarterMatrix.test.tsx @@ -0,0 +1,385 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { StarterMatrix } from '../StarterMatrix'; +import { invoke } from '../../testUtils/mocks/tauri'; +import type { + DownloadProgressInfo, + DownloadUiState, +} from '../../hooks/useDownloadModel'; +import type { Starter, StarterOption, StarterTier } from '../../types/starter'; + +function makeStarter(tier: StarterTier, overrides?: Partial): Starter { + return { + tier, + display_name: `Model ${tier}`, + repo: `org/${tier}-repo`, + revision: 'a'.repeat(40), + file_name: `${tier}.gguf`, + sha256: `${tier}-sha`, + size_bytes: 2_500_000_000, + quant: 'Q4_K_M', + vision: true, + thinking: false, + mmproj_file: null, + mmproj_sha256: null, + mmproj_bytes: 800_000_000, + est_runtime_gb: 5, + license_note: 'Gemma Terms of Use', + ...overrides, + }; +} + +function makeOption( + tier: StarterTier, + overrides?: Partial, + starterOverrides?: Partial, +): StarterOption { + return { + starter: makeStarter(tier, starterOverrides), + fit: 'fits', + installed: false, + partial_bytes: null, + ...overrides, + }; +} + +const THREE_TIERS: StarterOption[] = [ + makeOption('fast', { fit: 'fits' }, { vision: true }), + makeOption('balanced', { fit: 'tight' }, { vision: true }), + makeOption( + 'smartest', + { fit: 'too_big' }, + { vision: false, license_note: 'MIT' }, + ), +]; + +function renderMatrix( + options: StarterOption[], + props?: Partial[0]>, +) { + const handlers = { + onDownload: vi.fn(), + onResume: vi.fn(), + onDiscard: vi.fn(), + onCancel: vi.fn(), + onRetry: vi.fn(), + }; + const utils = render( + , + ); + return { ...utils, ...handlers }; +} + +const PROGRESS: DownloadProgressInfo = { + file: 'weights.gguf', + bytes: 1_400_000_000, + totalBytes: 2_500_000_000, +}; + +describe('StarterMatrix (picker)', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('renders the three tiers left to right with names, tiers and sizes', () => { + const { container } = renderMatrix(THREE_TIERS); + const cols = container.querySelectorAll('[data-tier-column]'); + expect(cols).toHaveLength(3); + expect(cols[0].getAttribute('data-tier')).toBe('fast'); + expect(cols[1].getAttribute('data-tier')).toBe('balanced'); + expect(cols[2].getAttribute('data-tier')).toBe('smartest'); + expect(screen.getByText('Model fast')).toBeInTheDocument(); + expect(screen.getByText('Balanced ★')).toBeInTheDocument(); + expect(screen.getByText('Fast')).toBeInTheDocument(); + expect(screen.getByText('Smartest')).toBeInTheDocument(); + // (2_500_000_000 + 850_000_000) / 1e9 = 3.35 -> "3.3 GB", one per column. + expect(screen.getAllByText('3.3 GB')).toHaveLength(3); + }); + + it('orders columns even when the backend returns them shuffled', () => { + const { container } = renderMatrix([ + THREE_TIERS[2], + THREE_TIERS[0], + THREE_TIERS[1], + ]); + const cols = container.querySelectorAll('[data-tier-column]'); + expect([...cols].map((c) => c.getAttribute('data-tier'))).toEqual([ + 'fast', + 'balanced', + 'smartest', + ]); + }); + + it('marks only the Balanced column as recommended', () => { + const { container } = renderMatrix(THREE_TIERS); + const rec = (tier: string) => + container + .querySelector(`[data-tier="${tier}"]`) + ?.getAttribute('data-recommended'); + expect(rec('balanced')).toBe('true'); + expect(rec('fast')).toBe('false'); + expect(rec('smartest')).toBe('false'); + }); + + it('renders Vision yes/no and the On-your-Mac fit copy', () => { + renderMatrix(THREE_TIERS); + expect(screen.getAllByText('Yes')).toHaveLength(2); // fast + balanced + expect(screen.getByText('—')).toBeInTheDocument(); // smartest text-only + expect(screen.getByText('Comfortable')).toBeInTheDocument(); + expect(screen.getByText('Tight')).toBeInTheDocument(); + expect(screen.getByText('Heavy')).toBeInTheDocument(); + }); + + it('opens the Hugging Face repo from the license cell', () => { + renderMatrix(THREE_TIERS); + expect(screen.getAllByText('Gemma Terms of Use ↗')).toHaveLength(2); + expect(screen.getByText('MIT ↗')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { + name: 'Open Model smartest on Hugging Face', + }), + ); + expect(invoke).toHaveBeenCalledWith('open_url', { + url: 'https://huggingface.co/org/smartest-repo', + }); + }); + + it('fires onDownload from a tier with no partial', () => { + const { onDownload } = renderMatrix([makeOption('smartest')]); + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(onDownload).toHaveBeenCalledWith('smartest'); + }); + + it('shows the installed line instead of a download button', () => { + renderMatrix([makeOption('fast', { installed: true })]); + expect(screen.getByText('Installed')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Download' }), + ).not.toBeInTheDocument(); + }); + + it('shows the recommended download button with a hover state', () => { + renderMatrix([makeOption('balanced')]); + const btn = screen.getByRole('button', { name: 'Download' }); + fireEvent.mouseEnter(btn); + fireEvent.mouseLeave(btn); + expect(btn).toBeInTheDocument(); + }); + + it('hovers a ghost (non-recommended) download button', () => { + renderMatrix([makeOption('fast')]); // fast = ghost (not recommended) + const dl = screen.getByRole('button', { name: 'Download' }); + fireEvent.mouseEnter(dl); + fireEvent.mouseLeave(dl); + expect(dl).toBeInTheDocument(); + }); + + it('offers Resume (bytes at rest, "Resume" on hover) + Discard for a partial', () => { + const { onResume, onDiscard } = renderMatrix([ + makeOption('fast', { partial_bytes: 1_200_000_000 }), + ]); + // 1.2 / 2.5 GB (size_bytes only, mirroring the download view). + expect(screen.getByText('1.2 / 2.5 GB')).toBeInTheDocument(); + const resume = screen.getByRole('button', { name: 'Resume download' }); + fireEvent.mouseEnter(resume); // reveals "Resume", covers the hover branch + fireEvent.click(resume); + expect(onResume).toHaveBeenCalledWith('fast', 1_200_000_000, 2_500_000_000); + fireEvent.mouseLeave(resume); + + fireEvent.click(screen.getByText('Discard partial')); + expect(onDiscard).toHaveBeenCalledWith('fast-sha'); + }); + + it('renders the active column download fill and cancels on click', () => { + const { onCancel } = renderMatrix(THREE_TIERS, { + state: { phase: 'downloading' }, + progress: PROGRESS, + etaSeconds: 30, + downloadingTier: 'fast', + }); + expect(screen.getByText('1.4 / 2.5 GB · 30s left')).toBeInTheDocument(); + const pause = screen.getByRole('button', { name: 'Pause download' }); + fireEvent.mouseEnter(pause); // cross-fade to grey/"Pause download" + fireEvent.click(pause); + expect(onCancel).toHaveBeenCalledTimes(1); + fireEvent.mouseLeave(pause); + }); + + it('dims and disables the other columns while one is downloading', () => { + const { container, onDownload } = renderMatrix(THREE_TIERS, { + state: { phase: 'downloading' }, + progress: PROGRESS, + etaSeconds: null, + downloadingTier: 'fast', + }); + // No ETA -> just the byte counts. + expect(screen.getByText('1.4 / 2.5 GB')).toBeInTheDocument(); + const balanced = container.querySelector('[data-tier="balanced"]'); + expect(balanced?.getAttribute('style')).toContain('opacity: 0.32'); + const downloads = screen.getAllByRole('button', { name: 'Download' }); + downloads.forEach((b) => expect(b).toBeDisabled()); + fireEvent.click(downloads[0]); + expect(onDownload).not.toHaveBeenCalled(); + }); + + it('formats minute- and hour-scale ETAs', () => { + const { rerender } = renderMatrix([makeOption('fast')], { + state: { phase: 'downloading' }, + progress: PROGRESS, + etaSeconds: 90, + downloadingTier: 'fast', + }); + expect(screen.getByText('1.4 / 2.5 GB · 1m left')).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText('1.4 / 2.5 GB · 1h 1m left')).toBeInTheDocument(); + }); + + it('shows "Starting…" before the first progress event', () => { + renderMatrix([makeOption('fast')], { + state: { phase: 'downloading' }, + progress: null, + downloadingTier: 'fast', + }); + expect(screen.getByText('Starting…')).toBeInTheDocument(); + }); + + it('labels the vision-companion phase', () => { + renderMatrix([makeOption('fast')], { + state: { phase: 'downloading_mmproj' }, + progress: { file: 'mmproj', bytes: 400_000_000, totalBytes: 800_000_000 }, + etaSeconds: 5, + downloadingTier: 'fast', + }); + expect(screen.getByText('0.4 / 0.8 GB · 5s left')).toBeInTheDocument(); + }); + + it('renders each post-download phase label', () => { + const phases: Array<[DownloadUiState['phase'], string]> = [ + ['verifying', 'Verifying'], + ['installing', 'Installing'], + ['warming_up', 'Starting engine'], + ['ready', 'Ready'], + ]; + for (const [phase, label] of phases) { + const { unmount } = renderMatrix([makeOption('fast')], { + state: { phase } as DownloadUiState, + downloadingTier: 'fast', + }); + expect(screen.getByText(label)).toBeInTheDocument(); + unmount(); + } + }); + + it('shows a failed headline + Retry, and leaves other columns usable', () => { + const { onRetry, onDownload } = renderMatrix(THREE_TIERS, { + state: { phase: 'failed', kind: 'disk_full', message: 'ENOSPC' }, + downloadingTier: 'fast', + }); + expect(screen.getByText('Not enough disk')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + // A failure does not lock the other tiers. + const downloads = screen.getAllByRole('button', { name: 'Download' }); + expect(downloads[0]).not.toBeDisabled(); + fireEvent.click(downloads[0]); + expect(onDownload).toHaveBeenCalled(); + }); + + it('renders every failure kind headline', () => { + const kinds: Array<[string, string]> = [ + ['offline', "You're offline"], + ['http', 'Download error'], + ['checksum', 'Verify failed'], + ['engine', 'Engine could not start'], + ['other', 'Download failed'], + ]; + for (const [kind, label] of kinds) { + const { unmount } = renderMatrix([makeOption('fast')], { + state: { phase: 'failed', kind, message: 'x' } as DownloadUiState, + downloadingTier: 'fast', + }); + expect(screen.getByText(label)).toBeInTheDocument(); + unmount(); + } + }); + + it('disables Resume and hides Discard while another tier downloads', () => { + const { onResume } = renderMatrix( + [ + makeOption('fast'), + makeOption('balanced', { partial_bytes: 1_000_000_000 }), + ], + { + state: { phase: 'downloading' }, + progress: PROGRESS, + downloadingTier: 'fast', + }, + ); + const resume = screen.getByRole('button', { name: 'Resume download' }); + expect(resume).toBeDisabled(); + fireEvent.mouseEnter(resume); // hover while disabled stays at rest + fireEvent.click(resume); + expect(onResume).not.toHaveBeenCalled(); + expect(screen.queryByText('Discard partial')).not.toBeInTheDocument(); + }); + + it('shows the Ollama escape hatch only when detected and wired', () => { + const onUseOllama = vi.fn(); + const { rerender } = renderMatrix(THREE_TIERS, { + ollamaDetected: true, + onUseOllama, + }); + fireEvent.click( + screen.getByRole('button', { name: 'Use my existing Ollama instead' }), + ); + expect(onUseOllama).toHaveBeenCalledTimes(1); + + const base = { + options: THREE_TIERS, + state: { phase: 'idle' } as DownloadUiState, + progress: null, + etaSeconds: null, + downloadingTier: null, + onDownload: vi.fn(), + onResume: vi.fn(), + onDiscard: vi.fn(), + onCancel: vi.fn(), + onRetry: vi.fn(), + }; + rerender( + , + ); + expect( + screen.queryByText('Use my existing Ollama instead'), + ).not.toBeInTheDocument(); + rerender(); + expect( + screen.queryByText('Use my existing Ollama instead'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/view/onboarding/ModelCheckStep.tsx b/src/view/onboarding/ModelCheckStep.tsx index b54c6b7c..1f0ff07b 100644 --- a/src/view/onboarding/ModelCheckStep.tsx +++ b/src/view/onboarding/ModelCheckStep.tsx @@ -22,17 +22,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import thukiLogo from '../../../src-tauri/icons/128x128.png'; import { useConfig } from '../../contexts/ConfigContext'; -import { - FIT_COPY, - StarterPicker, - useStarterOptions, -} from '../../components/StarterPicker'; -import { - DownloadProgress, - type ConfirmInfo, -} from '../../components/DownloadProgress'; +import { FIT_COPY, useStarterOptions } from '../../components/StarterPicker'; +import { StarterMatrix } from '../../components/StarterMatrix'; +import type { ConfirmInfo } from '../../components/DownloadProgress'; import { useDownloadModel, + type DownloadProgressInfo, type DownloadUiState, } from '../../hooks/useDownloadModel'; import type { StarterOption, StarterTier } from '../../types/starter'; @@ -195,19 +190,27 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { state, progress, etaSeconds, - beginConfirm, - cancelConfirm, start, cancel, retry, resume, discard, enterResumePending, - reset, } = useDownloadModel(); - const [selected, setSelected] = useState('balanced'); + // The tier whose download is in flight; the matrix renders the fill there + // and dims the rest. The matrix only reads it while a download is busy, so + // the last value lingering after one finishes or cancels is harmless. + const [downloadingTier, setDownloadingTier] = useState( + null, + ); + // On Resume the download restarts with no progress yet, which would flash + // the fill back to 0 before the resumed byte count arrives. Seeding the + // partial here keeps the fill parked at the paused position until the first + // real Progress event lands. Null for a fresh (non-resume) download. + const [resumeFrom, setResumeFrom] = useState( + null, + ); const [ollamaDetected, setOllamaDetected] = useState(false); - const [freeDiskBytes, setFreeDiskBytes] = useState(null); useEffect(() => { let cancelled = false; @@ -230,18 +233,30 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { .catch(() => { // Detection failure just hides the escape hatch. }); - void invoke('get_models_dir_free_bytes') - .then((bytes) => { - if (!cancelled) setFreeDiskBytes(bytes ?? null); - }) - .catch(() => { - // Unknown free space hides the disk line; never blocks the download. - }); return () => { cancelled = true; }; }, []); + // A cancelled download leaves a resumable partial on disk, but the picker's + // rows still carry the pre-cancel `partial_bytes`. When the machine returns + // to idle from an active phase (a cancel), re-fetch so the affected column + // offers Resume/Discard right away, not only after a relaunch. The ref keeps + // mount (already idle) and the resume_pending hop (Discard refreshes itself) + // from firing a redundant fetch. + const prevPhaseRef = useRef(state.phase); + useEffect(() => { + const prev = prevPhaseRef.current; + prevPhaseRef.current = state.phase; + if ( + state.phase === 'idle' && + prev !== 'idle' && + prev !== 'resume_pending' + ) { + void refresh(); + } + }, [state.phase, refresh]); + // An interrupted earlier download leaves a resumable partial: surface the // per-card Resume/Discard pair instead of the plain Download button. useEffect(() => { @@ -277,77 +292,43 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { onUseOllama(); }, [state.phase, cancel, onUseOllama]); - const pickerVisible = - state.phase === 'idle' || - state.phase === 'confirming' || - state.phase === 'resume_pending'; - const hatchBesideProgress = - ollamaDetected && - (isDownloadingPhase(state.phase) || state.phase === 'failed'); - return ( {options === null ? null : ( - <> - {pickerVisible ? ( -
- { - setSelected(tier); - beginConfirm(tier); - }} - onResume={(tier) => { - setSelected(tier); - void resume(tier); - }} - onDiscard={(sha256) => { - void discard(sha256).then(refresh); - }} - ollamaDetected={ollamaDetected} - onUseOllama={() => void handleUseOllama()} - /> - -
- ) : null} - + void start(selected)} - onCancelConfirm={cancelConfirm} + downloadingTier={downloadingTier} + onDownload={(tier) => { + setResumeFrom(null); + setDownloadingTier(tier); + void start(tier); + }} + onResume={(tier, partialBytes, sizeBytes) => { + // Seed the fill with the bytes already on disk so it stays at the + // paused position instead of flashing to 0 when the resumed + // download restarts. + setResumeFrom({ + file: '', + bytes: partialBytes, + totalBytes: sizeBytes, + }); + setDownloadingTier(tier); + void resume(tier); + }} + onDiscard={(sha256) => { + void discard(sha256).then(refresh); + }} onCancel={() => void cancel()} onRetry={() => void retry()} - // A failed download (disk full, checksum) must not trap the - // user on Retry: this steps back to the picker so a smaller - // tier stays reachable. - onChooseAnother={reset} + ollamaDetected={ollamaDetected} + onUseOllama={() => void handleUseOllama()} /> - {hatchBesideProgress ? ( - - ) : null} - + +
)} ); @@ -403,7 +384,7 @@ function BuiltinShell({ children }: { children: React.ReactNode }) { animate={{ opacity: 1, scale: 1, y: 0 }} transition={{ type: 'spring', stiffness: 300, damping: 28 }} style={{ - width: 420, + width: 720, background: 'radial-gradient(ellipse 80% 55% at 50% 0%, rgba(255,141,92,0.14) 0%, rgba(28,24,20,0.97) 60%), rgba(28,24,20,0.97)', border: '1px solid rgba(255, 141, 92, 0.2)', @@ -464,7 +445,7 @@ function BuiltinShell({ children }: { children: React.ReactNode }) { color: 'rgba(255,255,255,0.55)', lineHeight: 1.5, margin: '0 auto 18px', - maxWidth: 320, + maxWidth: 560, }} > Pick a starter brain for Thuki. Downloads once, then runs fully diff --git a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx index 36cb2d13..926a11c4 100644 --- a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx +++ b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx @@ -772,7 +772,7 @@ function renderBuiltin() { ); } -/** Clicks the per-card Download button, then confirms in the confirm card. */ +/** One tap on a column's Download starts the download directly (no confirm). */ async function startDownload(container: HTMLElement, tier: StarterTier) { const card = container.querySelector(`[data-tier="${tier}"]`)!; await act(async () => { @@ -780,14 +780,6 @@ async function startDownload(container: HTMLElement, tier: StarterTier) { within(card as HTMLElement).getByRole('button', { name: 'Download' }), ); }); - const progressCard = container.querySelector('[data-download-progress]')!; - await act(async () => { - fireEvent.click( - within(progressCard as HTMLElement).getByRole('button', { - name: 'Download', - }), - ); - }); } describe('ModelCheckStep (builtin flow)', () => { @@ -796,7 +788,7 @@ describe('ModelCheckStep (builtin flow)', () => { resetChannelCapture(); }); - it('renders the picker with Balanced preselected, the more-options stub, and the escape hatch', async () => { + it('renders the matrix with Balanced recommended, the more-options stub, and the escape hatch', async () => { builtinResponses(); const { container } = renderBuiltin(); @@ -805,12 +797,12 @@ describe('ModelCheckStep (builtin flow)', () => { expect( container .querySelector('[data-tier="balanced"]') - ?.getAttribute('data-selected'), + ?.getAttribute('data-recommended'), ).toBe('true'); expect( container .querySelector('[data-tier="fast"]') - ?.getAttribute('data-selected'), + ?.getAttribute('data-recommended'), ).toBe('false'); const stub = screen.getByRole('button', { name: 'More options · Full model browser coming soon', @@ -837,53 +829,14 @@ describe('ModelCheckStep (builtin flow)', () => { ).not.toBeInTheDocument(); }); - it('selecting another card moves the highlight', async () => { - builtinResponses(); - - const { container } = renderBuiltin(); - await act(async () => {}); - - await act(async () => { - fireEvent.click(container.querySelector('[data-tier="fast"]')!); - }); - expect( - container - .querySelector('[data-tier="fast"]') - ?.getAttribute('data-selected'), - ).toBe('true'); - }); - - it('one-tap download shows confirm facts, walks to ready, refreshes options, and advances', async () => { + it('one-tap download starts immediately (no confirm), walks to ready, refreshes, and advances', async () => { builtinResponses({ advance_past_model_check: undefined }); const { container } = renderBuiltin(); await act(async () => {}); - const balancedCard = container.querySelector('[data-tier="balanced"]')!; - await act(async () => { - fireEvent.click( - within(balancedCard as HTMLElement).getByRole('button', { - name: 'Download', - }), - ); - }); - - // Confirm card: size, free-disk line, RAM warning (balanced is 'tight': - // once on the picker badge, once in the confirm card). - expect(screen.getByText('7.3 GB download.')).toBeInTheDocument(); - expect(screen.getByText('50.0 GB free on this disk.')).toBeInTheDocument(); - expect( - screen.getAllByText("Will run, but close to this Mac's memory limit"), - ).toHaveLength(2); - - const progressCard = container.querySelector('[data-download-progress]')!; - await act(async () => { - fireEvent.click( - within(progressCard as HTMLElement).getByRole('button', { - name: 'Download', - }), - ); - }); + await startDownload(container as HTMLElement, 'balanced'); + // No confirm step: the download command fires straight away. expect(invoke).toHaveBeenCalledWith( 'download_starter', expect.objectContaining({ tier: 'balanced' }), @@ -896,9 +849,11 @@ describe('ModelCheckStep (builtin flow)', () => { data: { file: 'balanced.gguf', total_bytes: 100, resumed_from: 0 }, }); }); - expect(screen.getByText('Downloading model')).toBeInTheDocument(); - // The picker is hidden while the download runs. - expect(container.querySelector('[data-starter-card]')).toBeNull(); + // The active column fills in place; the matrix itself stays mounted. + expect(container.querySelector('[data-starter-matrix]')).not.toBeNull(); + expect( + screen.getByRole('button', { name: 'Pause download' }), + ).toBeInTheDocument(); await act(async () => { channel.simulateMessage({ type: 'AllDone' }); @@ -940,69 +895,24 @@ describe('ModelCheckStep (builtin flow)', () => { expect(invoke).not.toHaveBeenCalledWith('advance_past_model_check'); }); - it('hides the disk line and the hatch when the auxiliary probes reject', async () => { + it('hides the escape hatch when the detect probe rejects', async () => { builtinResponses(); const base = invoke.getMockImplementation()!; invoke.mockImplementation(async (cmd, args) => { if (cmd === 'detect_ollama') throw new Error('down'); - if (cmd === 'get_models_dir_free_bytes') throw new Error('down'); return base(cmd, args); }); - const { container } = renderBuiltin(); + renderBuiltin(); await act(async () => {}); expect( screen.queryByText('Use my existing Ollama instead'), ).not.toBeInTheDocument(); - - const balancedCard = container.querySelector('[data-tier="balanced"]')!; - await act(async () => { - fireEvent.click( - within(balancedCard as HTMLElement).getByRole('button', { - name: 'Download', - }), - ); - }); - expect(screen.getByText('7.3 GB download.')).toBeInTheDocument(); - expect(screen.queryByText(/free on this disk/)).not.toBeInTheDocument(); - }); - - it('cancel from the confirm card returns to the picker', async () => { - // A null free-bytes answer (backend could not stat the volume) hides - // the disk line instead of blocking the flow. - builtinResponses({ get_models_dir_free_bytes: null }); - - const { container } = renderBuiltin(); - await act(async () => {}); - - const fastCard = container.querySelector('[data-tier="fast"]')!; - await act(async () => { - fireEvent.click( - within(fastCard as HTMLElement).getByRole('button', { - name: 'Download', - }), - ); - }); - // 'fast' fits comfortably: no RAM warning inside the confirm card (the - // picker badge keeps its single copy), and the null free-bytes answer - // drops the disk line. - expect(screen.getAllByText('Runs comfortably on this Mac')).toHaveLength(1); - expect(screen.queryByText(/free on this disk/)).not.toBeInTheDocument(); - - const progressCard = container.querySelector('[data-download-progress]')!; - await act(async () => { - fireEvent.click( - within(progressCard as HTMLElement).getByRole('button', { - name: 'Cancel', - }), - ); - }); - expect(screen.queryByText('7.3 GB download.')).not.toBeInTheDocument(); expect(screen.getByText('Model balanced')).toBeInTheDocument(); }); - it('cancel during download invokes cancel_model_download and returns to the picker', async () => { + it('pausing a download cancels it and returns the matrix to its download buttons', async () => { builtinResponses({ cancel_model_download: undefined }); const { container } = renderBuiltin(); @@ -1018,17 +928,20 @@ describe('ModelCheckStep (builtin flow)', () => { }); await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.click(screen.getByRole('button', { name: 'Pause download' })); }); expect(invoke).toHaveBeenCalledWith('cancel_model_download'); await act(async () => { channel.simulateMessage({ type: 'Cancelled' }); }); - expect(screen.getByText('Model balanced')).toBeInTheDocument(); + // Back to the matrix's plain Download buttons. + expect( + screen.getAllByRole('button', { name: 'Download' }).length, + ).toBeGreaterThan(0); }); - it('shows resume and discard when an option carries a resumable partial', async () => { + it('resumes from a partial, showing the bytes and re-invoking the download', async () => { const withPartial = [ makeOption('fast'), makeOption('balanced', { fit: 'tight', partial_bytes: 1_200_000_000 }), @@ -1039,11 +952,10 @@ describe('ModelCheckStep (builtin flow)', () => { renderBuiltin(); await act(async () => {}); - const resume = screen.getByRole('button', { - name: 'Resume download (1.2 of 7.3 GB)', - }); + // 1.2 of the 7.3 GB weights file, mirroring the download view. + expect(screen.getByText('1.2 / 7.3 GB')).toBeInTheDocument(); await act(async () => { - fireEvent.click(resume); + fireEvent.click(screen.getByRole('button', { name: 'Resume download' })); }); expect(invoke).toHaveBeenCalledWith( 'download_starter', @@ -1066,7 +978,7 @@ describe('ModelCheckStep (builtin flow)', () => { await act(async () => {}); await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + fireEvent.click(screen.getByText('Discard partial')); }); expect(invoke).toHaveBeenCalledWith('discard_partial_download', { sha256: 'b'.repeat(64), @@ -1182,7 +1094,7 @@ describe('ModelCheckStep (builtin flow)', () => { }); }); - expect(screen.getByText('You appear to be offline.')).toBeInTheDocument(); + expect(screen.getByText("You're offline")).toBeInTheDocument(); expect( screen.getByText('Use my existing Ollama instead'), ).toBeInTheDocument(); @@ -1195,7 +1107,7 @@ describe('ModelCheckStep (builtin flow)', () => { ).toHaveLength(2); }); - it('Choose a different model on the failed card returns to the picker', async () => { + it('leaves the other tiers usable after a failure (no lock, no "choose another")', async () => { builtinResponses(); const { container } = renderBuiltin(); @@ -1209,22 +1121,22 @@ describe('ModelCheckStep (builtin flow)', () => { data: { kind: 'disk_full', message: 'no space left' }, }); }); - expect( - screen.getByText('Not enough disk space. Free up space and retry.'), - ).toBeInTheDocument(); - // The picker hides while the failed card is up; a user who now wants - // the smaller Fast tier needs this explicit way back. - expect(container.querySelector('[data-tier="fast"]')).toBeNull(); + expect(screen.getByText('Not enough disk')).toBeInTheDocument(); + // The Fast column stays in the matrix and is immediately downloadable; + // there is no separate "choose another" affordance. + const fast = container.querySelector('[data-tier="fast"]')!; + const fastDownload = within(fast as HTMLElement).getByRole('button', { + name: 'Download', + }); + expect(fastDownload).not.toBeDisabled(); await act(async () => { - fireEvent.click( - screen.getByRole('button', { name: 'Choose a different model' }), - ); + fireEvent.click(fastDownload); }); - expect(container.querySelector('[data-tier="fast"]')).not.toBeNull(); - expect( - screen.queryByText('Not enough disk space. Free up space and retry.'), - ).not.toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith( + 'download_starter', + expect.objectContaining({ tier: 'fast' }), + ); }); it('drops probe results that resolve after unmount', async () => { From 93b1ded9b452718c7967b9331fce1e4cb52aeaa2 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 10:44:33 -0500 Subject: [PATCH 15/51] feat: switch starters to Gemma 4 E4B and 12B (Apache 2.0) Signed-off-by: Logan Nguyen --- src-tauri/src/models/registry.rs | 67 ++++++++++++++++---------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/models/registry.rs b/src-tauri/src/models/registry.rs index c105a153..f5641037 100644 --- a/src-tauri/src/models/registry.rs +++ b/src-tauri/src/models/registry.rs @@ -8,8 +8,9 @@ * [`crate::models::download::DownloadSpec`] which verifies them on install). * * Hashes and sizes were read from the Hugging Face tree-at-revision API - * (`/api/models//tree/`) on 2026-06-10, so each digest - * matches the pinned commit, not whatever `main` later points to. + * (`/api/models//tree/`) on 2026-06-17 for the Gemma 4 tiers + * and 2026-06-10 for Phi-4, so each digest matches the pinned commit, not + * whatever `main` later points to. */ use crate::config::defaults::HF_BASE_URL; @@ -31,7 +32,7 @@ pub enum Tier { pub struct Starter { /// Which speed/quality tier this entry fills. pub tier: Tier, - /// Human-readable label shown in the picker (e.g. "Gemma 3 4B"). + /// Human-readable label shown in the picker (e.g. "Gemma 4 E4B"). pub display_name: &'static str, /// Hugging Face repo slug. pub repo: &'static str, @@ -69,37 +70,37 @@ pub struct Starter { pub const STARTERS: &[Starter] = &[ Starter { tier: Tier::Fast, - display_name: "Gemma 3 4B", - repo: "ggml-org/gemma-3-4b-it-GGUF", - revision: "d0976223747697cb51e056d85c532013931fe52e", - file_name: "gemma-3-4b-it-Q4_K_M.gguf", - sha256: "882e8d2db44dc554fb0ea5077cb7e4bc49e7342a1f0da57901c0802ea21a0863", - size_bytes: 2_489_757_856, + display_name: "Gemma 4 E4B", + repo: "ggml-org/gemma-4-E4B-it-GGUF", + revision: "2714b5519c6c3516b1000e7c5e1eba998dfe1fe8", + file_name: "gemma-4-E4B-it-Q4_K_M.gguf", + sha256: "90ce98129eb3e8cc57e62433d500c97c624b1e3af1fcc85dd3b55ad7e0313e9f", + size_bytes: 5_335_289_824, quant: "Q4_K_M", vision: true, thinking: false, - mmproj_file: Some("mmproj-model-f16.gguf"), - mmproj_sha256: Some("8c0fb064b019a6972856aaae2c7e4792858af3ca4561be2dbf649123ba6c40cb"), - mmproj_bytes: 851_251_104, - est_runtime_gb: 5.0, - license_note: "Gemma Terms of Use", + mmproj_file: Some("mmproj-gemma-4-E4B-it-Q8_0.gguf"), + mmproj_sha256: Some("51d4b7fd825e4569f746b200fccc5332bf914e8ef7cbe447272ce4fec6df3db6"), + mmproj_bytes: 559_874_528, + est_runtime_gb: 7.0, + license_note: "Apache 2.0", }, Starter { tier: Tier::Balanced, - display_name: "Gemma 3 12B", - repo: "ggml-org/gemma-3-12b-it-GGUF", - revision: "ec0cbabd8dbff316f659876a50202295c3c4a314", - file_name: "gemma-3-12b-it-Q4_K_M.gguf", - sha256: "7bb69bff3f48a7b642355d64a90e481182a7794707b3133890646b1efa778ff5", - size_bytes: 7_300_574_976, + display_name: "Gemma 4 12B", + repo: "ggml-org/gemma-4-12B-it-GGUF", + revision: "44ee90c4b61e888ac5b318a54ec7a94df61e9cd7", + file_name: "gemma-4-12B-it-Q4_K_M.gguf", + sha256: "1278394b693672ac2799eadc9a83fd98259a6a88a40acfb1dcaa6c6fc895a606", + size_bytes: 7_381_382_048, quant: "Q4_K_M", vision: true, thinking: false, - mmproj_file: Some("mmproj-model-f16.gguf"), - mmproj_sha256: Some("30c02d056410848227001830866e0a269fcc28aaf8ca971bded494003de9f5a5"), - mmproj_bytes: 854_200_224, - est_runtime_gb: 11.5, - license_note: "Gemma Terms of Use", + mmproj_file: Some("mmproj-gemma-4-12B-it-Q8_0.gguf"), + mmproj_sha256: Some("b486d28398a398db4fa14cc4b032252ad3a8d7f950b9fabd93f5c8b4d4dde52b"), + mmproj_bytes: 158_987_584, + est_runtime_gb: 10.0, + license_note: "Apache 2.0", }, Starter { tier: Tier::Smartest, @@ -249,10 +250,10 @@ mod tests { #[test] fn license_notes_per_tier() { - // The picker surfaces these verbatim: both Gemma tiers carry the - // Gemma Terms of Use, the Phi-4 tier is MIT. - assert_eq!(starter(Tier::Fast).license_note, "Gemma Terms of Use"); - assert_eq!(starter(Tier::Balanced).license_note, "Gemma Terms of Use"); + // The picker surfaces these verbatim: both Gemma 4 tiers are Apache + // 2.0, the Phi-4 tier is MIT. + assert_eq!(starter(Tier::Fast).license_note, "Apache 2.0"); + assert_eq!(starter(Tier::Balanced).license_note, "Apache 2.0"); assert_eq!(starter(Tier::Smartest).license_note, "MIT"); } @@ -260,8 +261,8 @@ mod tests { fn mmproj_hashes_are_distinct_between_gemma_tiers() { let fast = starter(Tier::Fast); let balanced = starter(Tier::Balanced); - // Both Gemma mmproj files share a name but differ in size, so their - // hashes must differ; identical hashes would mean a swap happened. + // The two Gemma 4 tiers ship their own mmproj; the sizes and hashes + // must differ, or a copy/paste swap slipped in. assert_ne!(fast.mmproj_bytes, balanced.mmproj_bytes); assert_ne!(fast.mmproj_sha256.unwrap(), balanced.mmproj_sha256.unwrap()); } @@ -269,9 +270,9 @@ mod tests { #[test] fn fit_cutoffs() { const GIB: u64 = 1 << 30; - // (ram_gib, expected fit for Fast 5.0 / Balanced 11.5 / Smartest 10.7) + // (ram_gib, expected fit for Fast 7.0 / Balanced 10.0 / Smartest 10.7) let table: &[(u64, [RamFit; 3])] = &[ - (8, [RamFit::Tight, RamFit::TooBig, RamFit::TooBig]), + (8, [RamFit::TooBig, RamFit::TooBig, RamFit::TooBig]), (16, [RamFit::Fits, RamFit::Tight, RamFit::Tight]), (24, [RamFit::Fits, RamFit::Fits, RamFit::Fits]), (32, [RamFit::Fits, RamFit::Fits, RamFit::Fits]), From ca0e2795ed56cd31a7173fe08819f2ff157d0e28 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 11:37:59 -0500 Subject: [PATCH 16/51] feat: retune starters to Qwen3.5 9B, Gemma 4 12B QAT, gpt-oss 20B Signed-off-by: Logan Nguyen --- src-tauri/src/models/registry.rs | 103 +++++++++++++++++-------------- 1 file changed, 56 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/models/registry.rs b/src-tauri/src/models/registry.rs index f5641037..7bde1ce3 100644 --- a/src-tauri/src/models/registry.rs +++ b/src-tauri/src/models/registry.rs @@ -8,9 +8,8 @@ * [`crate::models::download::DownloadSpec`] which verifies them on install). * * Hashes and sizes were read from the Hugging Face tree-at-revision API - * (`/api/models//tree/`) on 2026-06-17 for the Gemma 4 tiers - * and 2026-06-10 for Phi-4, so each digest matches the pinned commit, not - * whatever `main` later points to. + * (`/api/models//tree/`) on 2026-06-17, so each digest + * matches the pinned commit, not whatever `main` later points to. */ use crate::config::defaults::HF_BASE_URL; @@ -32,7 +31,7 @@ pub enum Tier { pub struct Starter { /// Which speed/quality tier this entry fills. pub tier: Tier, - /// Human-readable label shown in the picker (e.g. "Gemma 4 E4B"). + /// Human-readable label shown in the picker (e.g. "Gemma 4 12B"). pub display_name: &'static str, /// Hugging Face repo slug. pub repo: &'static str, @@ -70,54 +69,54 @@ pub struct Starter { pub const STARTERS: &[Starter] = &[ Starter { tier: Tier::Fast, - display_name: "Gemma 4 E4B", - repo: "ggml-org/gemma-4-E4B-it-GGUF", - revision: "2714b5519c6c3516b1000e7c5e1eba998dfe1fe8", - file_name: "gemma-4-E4B-it-Q4_K_M.gguf", - sha256: "90ce98129eb3e8cc57e62433d500c97c624b1e3af1fcc85dd3b55ad7e0313e9f", - size_bytes: 5_335_289_824, + display_name: "Qwen3.5 9B", + repo: "unsloth/Qwen3.5-9B-GGUF", + revision: "3885219b6810b007914f3a7950a8d1b469d598a5", + file_name: "Qwen3.5-9B-Q4_K_M.gguf", + sha256: "03b74727a860a56338e042c4420bb3f04b2fec5734175f4cb9fa853daf52b7e8", + size_bytes: 5_680_522_464, quant: "Q4_K_M", vision: true, thinking: false, - mmproj_file: Some("mmproj-gemma-4-E4B-it-Q8_0.gguf"), - mmproj_sha256: Some("51d4b7fd825e4569f746b200fccc5332bf914e8ef7cbe447272ce4fec6df3db6"), - mmproj_bytes: 559_874_528, - est_runtime_gb: 7.0, + mmproj_file: Some("mmproj-BF16.gguf"), + mmproj_sha256: Some("853698ce7aa6c7ba732478bad280240969ddf7b0fcbf93900046f63903a83383"), + mmproj_bytes: 921_705_024, + est_runtime_gb: 8.5, license_note: "Apache 2.0", }, Starter { tier: Tier::Balanced, display_name: "Gemma 4 12B", - repo: "ggml-org/gemma-4-12B-it-GGUF", - revision: "44ee90c4b61e888ac5b318a54ec7a94df61e9cd7", - file_name: "gemma-4-12B-it-Q4_K_M.gguf", - sha256: "1278394b693672ac2799eadc9a83fd98259a6a88a40acfb1dcaa6c6fc895a606", - size_bytes: 7_381_382_048, - quant: "Q4_K_M", + repo: "google/gemma-4-12B-it-qat-q4_0-gguf", + revision: "f6e7774e6148da3b7f201e42ba37cf084c1db35f", + file_name: "gemma-4-12b-it-qat-q4_0.gguf", + sha256: "faff1a63667fac17ac5e777f47114688fcefea96e220e211aaa8d62c2c4561f1", + size_bytes: 6_975_877_728, + quant: "Q4_0", vision: true, thinking: false, - mmproj_file: Some("mmproj-gemma-4-12B-it-Q8_0.gguf"), - mmproj_sha256: Some("b486d28398a398db4fa14cc4b032252ad3a8d7f950b9fabd93f5c8b4d4dde52b"), - mmproj_bytes: 158_987_584, - est_runtime_gb: 10.0, + mmproj_file: Some("mmproj-gemma-4-12b-it-qat-q4_0.gguf"), + mmproj_sha256: Some("e70b0e5cd80323d5d588b4ed06780356b7b1ba03995a4b8164c6ae9db0ff5989"), + mmproj_bytes: 175_115_264, + est_runtime_gb: 9.5, license_note: "Apache 2.0", }, Starter { tier: Tier::Smartest, - display_name: "Phi-4 14B", - repo: "bartowski/phi-4-GGUF", - revision: "19cd65f97c2f1712a81c506611d3f9c94b16a1e1", - file_name: "phi-4-Q4_K_M.gguf", - sha256: "009aba717c09d4a35890c7d35eb59d54e1dba884c7c526e7197d9c13ab5911d9", - size_bytes: 9_053_114_816, - quant: "Q4_K_M", + display_name: "gpt-oss 20B", + repo: "ggml-org/gpt-oss-20b-GGUF", + revision: "e1dc459feff949ff451ce107337a2026daa80df8", + file_name: "gpt-oss-20b-mxfp4.gguf", + sha256: "be37a636aca0fc1aae0d32325f82f6b4d21495f06823b5fbc1898ae0303e9935", + size_bytes: 12_109_566_560, + quant: "MXFP4", vision: false, thinking: false, mmproj_file: None, mmproj_sha256: None, mmproj_bytes: 0, - est_runtime_gb: 10.7, - license_note: "MIT", + est_runtime_gb: 13.3, + license_note: "Apache 2.0", }, ]; @@ -209,12 +208,22 @@ mod tests { } #[test] - fn balanced_is_vision() { - let balanced = starter(Tier::Balanced); - assert!(balanced.vision); - assert!(balanced.mmproj_file.is_some()); - assert!(balanced.mmproj_sha256.is_some()); - assert!(balanced.mmproj_bytes > 0); + fn vision_and_mmproj_per_tier() { + // Fast (Qwen3.5) and Balanced (Gemma 4) are multimodal and each carries + // a vision projector; Smartest (gpt-oss) is text-only, so it has no + // mmproj companion at all. + for tier in [Tier::Fast, Tier::Balanced] { + let s = starter(tier); + assert!(s.vision, "{tier:?} should be a vision tier"); + assert!(s.mmproj_file.is_some()); + assert!(s.mmproj_sha256.is_some()); + assert!(s.mmproj_bytes > 0); + } + let smartest = starter(Tier::Smartest); + assert!(!smartest.vision); + assert!(smartest.mmproj_file.is_none()); + assert!(smartest.mmproj_sha256.is_none()); + assert_eq!(smartest.mmproj_bytes, 0); } #[test] @@ -250,19 +259,19 @@ mod tests { #[test] fn license_notes_per_tier() { - // The picker surfaces these verbatim: both Gemma 4 tiers are Apache - // 2.0, the Phi-4 tier is MIT. + // The picker surfaces these verbatim. Every tier ships under a + // permissive license: Qwen3.5, Gemma 4, and gpt-oss are all Apache 2.0. assert_eq!(starter(Tier::Fast).license_note, "Apache 2.0"); assert_eq!(starter(Tier::Balanced).license_note, "Apache 2.0"); - assert_eq!(starter(Tier::Smartest).license_note, "MIT"); + assert_eq!(starter(Tier::Smartest).license_note, "Apache 2.0"); } #[test] - fn mmproj_hashes_are_distinct_between_gemma_tiers() { + fn mmproj_hashes_are_distinct_between_vision_tiers() { let fast = starter(Tier::Fast); let balanced = starter(Tier::Balanced); - // The two Gemma 4 tiers ship their own mmproj; the sizes and hashes - // must differ, or a copy/paste swap slipped in. + // The two vision tiers (Qwen3.5 and Gemma 4) ship their own mmproj; the + // sizes and hashes must differ, or a copy/paste swap slipped in. assert_ne!(fast.mmproj_bytes, balanced.mmproj_bytes); assert_ne!(fast.mmproj_sha256.unwrap(), balanced.mmproj_sha256.unwrap()); } @@ -270,10 +279,10 @@ mod tests { #[test] fn fit_cutoffs() { const GIB: u64 = 1 << 30; - // (ram_gib, expected fit for Fast 7.0 / Balanced 10.0 / Smartest 10.7) + // (ram_gib, expected fit for Fast 8.5 / Balanced 9.5 / Smartest 13.3) let table: &[(u64, [RamFit; 3])] = &[ (8, [RamFit::TooBig, RamFit::TooBig, RamFit::TooBig]), - (16, [RamFit::Fits, RamFit::Tight, RamFit::Tight]), + (16, [RamFit::Fits, RamFit::Fits, RamFit::Tight]), (24, [RamFit::Fits, RamFit::Fits, RamFit::Fits]), (32, [RamFit::Fits, RamFit::Fits, RamFit::Fits]), ]; From 3add6776bc05d4e0cbc760a3c2885ac49a5a8732 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 12:12:54 -0500 Subject: [PATCH 17/51] feat: show each starter's origin in the picker with a verify link Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 4 +- src-tauri/src/models/registry.rs | 31 ++++++++ src/components/StarterMatrix.tsx | 70 ++++++++++++++----- .../__tests__/StarterMatrix.test.tsx | 17 +++++ .../__tests__/StarterPicker.test.tsx | 2 + src/settings/tabs/ProviderCards.test.tsx | 2 + src/types/starter.ts | 4 ++ .../__tests__/ModelCheckStep.test.tsx | 2 + 8 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ecb3ee4b..fa0e90ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -200,7 +200,7 @@ const ONBOARDING_EVENT: &str = "thuki://onboarding"; const ONBOARDING_LOGICAL_WIDTH: f64 = 460.0; const ONBOARDING_LOGICAL_HEIGHT: f64 = 640.0; const ONBOARDING_PICKER_WIDTH: f64 = 860.0; -const ONBOARDING_PICKER_HEIGHT: f64 = 700.0; +const ONBOARDING_PICKER_HEIGHT: f64 = 744.0; /// Per-stage onboarding window size. The model-picker step needs a wide /// frame for the comparison matrix; every other step keeps the compact base @@ -2341,7 +2341,7 @@ mod tests { assert_eq!(ONBOARDING_LOGICAL_WIDTH, 460.0); assert_eq!(ONBOARDING_LOGICAL_HEIGHT, 640.0); assert_eq!(ONBOARDING_PICKER_WIDTH, 860.0); - assert_eq!(ONBOARDING_PICKER_HEIGHT, 700.0); + assert_eq!(ONBOARDING_PICKER_HEIGHT, 744.0); } #[test] diff --git a/src-tauri/src/models/registry.rs b/src-tauri/src/models/registry.rs index 7bde1ce3..e591765a 100644 --- a/src-tauri/src/models/registry.rs +++ b/src-tauri/src/models/registry.rs @@ -63,6 +63,12 @@ pub struct Starter { pub est_runtime_gb: f64, /// Short license label surfaced next to the download button. pub license_note: &'static str, + /// Model maker (e.g. "OpenAI"), shown in the picker's Origin row. + pub origin: &'static str, + /// The maker's own official Hugging Face repo, opened from the Origin row + /// so a user can verify provenance on the source org's page. Differs from + /// `repo` (the GGUF download source) when a third party hosts the GGUF. + pub origin_repo: &'static str, } /// The curated starters, ordered Fast, Balanced, Smartest. @@ -83,6 +89,8 @@ pub const STARTERS: &[Starter] = &[ mmproj_bytes: 921_705_024, est_runtime_gb: 8.5, license_note: "Apache 2.0", + origin: "Alibaba", + origin_repo: "Qwen/Qwen3.5-9B", }, Starter { tier: Tier::Balanced, @@ -100,6 +108,8 @@ pub const STARTERS: &[Starter] = &[ mmproj_bytes: 175_115_264, est_runtime_gb: 9.5, license_note: "Apache 2.0", + origin: "Google", + origin_repo: "google/gemma-4-12B-it", }, Starter { tier: Tier::Smartest, @@ -117,6 +127,8 @@ pub const STARTERS: &[Starter] = &[ mmproj_bytes: 0, est_runtime_gb: 13.3, license_note: "Apache 2.0", + origin: "OpenAI", + origin_repo: "openai/gpt-oss-20b", }, ]; @@ -266,6 +278,25 @@ mod tests { assert_eq!(starter(Tier::Smartest).license_note, "Apache 2.0"); } + #[test] + fn origin_per_tier() { + // The picker's Origin row links to each maker's own official HF page + // for verification; the maker can differ from the GGUF download repo. + let cases = [ + (Tier::Fast, "Alibaba", "Qwen/Qwen3.5-9B"), + (Tier::Balanced, "Google", "google/gemma-4-12B-it"), + (Tier::Smartest, "OpenAI", "openai/gpt-oss-20b"), + ]; + for (tier, origin, origin_repo) in cases { + let s = starter(tier); + assert_eq!(s.origin, origin); + assert_eq!(s.origin_repo, origin_repo); + // origin_repo is an "org/name" slug the picker turns into an HF URL. + assert_eq!(s.origin_repo.split('/').count(), 2); + assert!(!s.origin.is_empty()); + } + } + #[test] fn mmproj_hashes_are_distinct_between_vision_tiers() { let fast = starter(Tier::Fast); diff --git a/src/components/StarterMatrix.tsx b/src/components/StarterMatrix.tsx index 784f1074..3666c1f7 100644 --- a/src/components/StarterMatrix.tsx +++ b/src/components/StarterMatrix.tsx @@ -246,6 +246,7 @@ function LabelColumn() { {cell('Quality')} {cell('Vision')} {cell('On your Mac')} + {cell('Origin')} {cell('License')}
); @@ -370,26 +371,21 @@ function TierColumn({ - + {starter.origin} + + + + + + {starter.license_note} + {/* Action: the filling download cell when this column is active, @@ -474,6 +470,42 @@ function ValueCell({ children }: { children: React.ReactNode }) { ); } +/** A small "↗" link inside a trait cell that opens a Hugging Face repo page. + * Shared by the Origin row (the model maker's official page) and the License + * row (the GGUF download source). */ +function ProvenanceLink({ + repo, + ariaLabel, + children, +}: { + repo: string; + ariaLabel: string; + children: React.ReactNode; +}) { + return ( + + ); +} + interface DownloadCellProps { state: DownloadUiState; progress: DownloadProgressInfo | null; diff --git a/src/components/__tests__/StarterMatrix.test.tsx b/src/components/__tests__/StarterMatrix.test.tsx index a5bd22a4..e451da04 100644 --- a/src/components/__tests__/StarterMatrix.test.tsx +++ b/src/components/__tests__/StarterMatrix.test.tsx @@ -25,6 +25,8 @@ function makeStarter(tier: StarterTier, overrides?: Partial): Starter { mmproj_bytes: 800_000_000, est_runtime_gb: 5, license_note: 'Gemma Terms of Use', + origin: 'TestMaker', + origin_repo: `maker/${tier}-repo`, ...overrides, }; } @@ -152,6 +154,21 @@ describe('StarterMatrix (picker)', () => { }); }); + it('opens the maker page from the origin cell', () => { + renderMatrix(THREE_TIERS); + // Origin defaults to 'TestMaker' for every tier; the link uses + // origin_repo (the maker's page), distinct from the license repo. + expect(screen.getAllByText('TestMaker ↗')).toHaveLength(3); + fireEvent.click( + screen.getByRole('button', { + name: 'Verify Model smartest: open its maker TestMaker on Hugging Face', + }), + ); + expect(invoke).toHaveBeenCalledWith('open_url', { + url: 'https://huggingface.co/maker/smartest-repo', + }); + }); + it('fires onDownload from a tier with no partial', () => { const { onDownload } = renderMatrix([makeOption('smartest')]); fireEvent.click(screen.getByRole('button', { name: 'Download' })); diff --git a/src/components/__tests__/StarterPicker.test.tsx b/src/components/__tests__/StarterPicker.test.tsx index 92bd785a..657994f8 100644 --- a/src/components/__tests__/StarterPicker.test.tsx +++ b/src/components/__tests__/StarterPicker.test.tsx @@ -27,6 +27,8 @@ function makeStarter(tier: StarterTier, overrides?: Partial): Starter { mmproj_bytes: 0, est_runtime_gb: 10, license_note: 'MIT', + origin: 'TestMaker', + origin_repo: `maker/${tier}-repo`, ...overrides, }; } diff --git a/src/settings/tabs/ProviderCards.test.tsx b/src/settings/tabs/ProviderCards.test.tsx index b8744c8d..31c1d164 100644 --- a/src/settings/tabs/ProviderCards.test.tsx +++ b/src/settings/tabs/ProviderCards.test.tsx @@ -141,6 +141,8 @@ const STARTER_OPTION: StarterOption = { mmproj_bytes: 0, est_runtime_gb: 6, license_note: '', + origin: 'Google', + origin_repo: 'google/gemma-4-12B-it', }, fit: 'fits', installed: false, diff --git a/src/types/starter.ts b/src/types/starter.ts index 449a90c1..094ab21e 100644 --- a/src/types/starter.ts +++ b/src/types/starter.ts @@ -33,6 +33,10 @@ export interface Starter { mmproj_bytes: number; est_runtime_gb: number; license_note: string; + /** Model maker shown in the Origin row (e.g. "OpenAI"). */ + origin: string; + /** The maker's own official HF repo, opened from the Origin row to verify provenance. */ + origin_repo: string; } /** One starter picker row: registry entry plus machine-specific facts. */ diff --git a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx index 926a11c4..044cb021 100644 --- a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx +++ b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx @@ -722,6 +722,8 @@ function makeStarter(tier: StarterTier, overrides?: Partial): Starter { mmproj_bytes: 0, est_runtime_gb: 10, license_note: 'MIT', + origin: 'TestMaker', + origin_repo: `maker/${tier}-repo`, ...overrides, }; } From b03812ee8ded06d2f66039557267e3fd8512783b Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 12:57:33 -0500 Subject: [PATCH 18/51] feat: reword the Ollama escape hatch and drop the model-browser stub Signed-off-by: Logan Nguyen --- src/components/StarterMatrix.tsx | 32 ++++++++++++------- .../__tests__/StarterMatrix.test.tsx | 12 ++----- src/view/onboarding/ModelCheckStep.tsx | 29 ----------------- .../__tests__/ModelCheckStep.test.tsx | 30 +++++------------ 4 files changed, 31 insertions(+), 72 deletions(-) diff --git a/src/components/StarterMatrix.tsx b/src/components/StarterMatrix.tsx index 3666c1f7..4e805555 100644 --- a/src/components/StarterMatrix.tsx +++ b/src/components/StarterMatrix.tsx @@ -199,23 +199,31 @@ export function StarterMatrix({ })}
{ollamaDetected && onUseOllama ? ( - + Already running Ollama?{' '} + +
) : null} ); diff --git a/src/components/__tests__/StarterMatrix.test.tsx b/src/components/__tests__/StarterMatrix.test.tsx index e451da04..928d153d 100644 --- a/src/components/__tests__/StarterMatrix.test.tsx +++ b/src/components/__tests__/StarterMatrix.test.tsx @@ -367,9 +367,7 @@ describe('StarterMatrix (picker)', () => { ollamaDetected: true, onUseOllama, }); - fireEvent.click( - screen.getByRole('button', { name: 'Use my existing Ollama instead' }), - ); + fireEvent.click(screen.getByRole('button', { name: 'Use it instead' })); expect(onUseOllama).toHaveBeenCalledTimes(1); const base = { @@ -391,12 +389,8 @@ describe('StarterMatrix (picker)', () => { onUseOllama={onUseOllama} />, ); - expect( - screen.queryByText('Use my existing Ollama instead'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Use it instead')).not.toBeInTheDocument(); rerender(); - expect( - screen.queryByText('Use my existing Ollama instead'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Use it instead')).not.toBeInTheDocument(); }); }); diff --git a/src/view/onboarding/ModelCheckStep.tsx b/src/view/onboarding/ModelCheckStep.tsx index 1f0ff07b..e9a6cdbb 100644 --- a/src/view/onboarding/ModelCheckStep.tsx +++ b/src/view/onboarding/ModelCheckStep.tsx @@ -327,41 +327,12 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { ollamaDetected={ollamaDetected} onUseOllama={() => void handleUseOllama()} /> - )} ); } -/** - * Phase 3 stub: the full model browser (paste-a-repo, search) ships later. - * Disabled on purpose; the styling marks it as a preview, not a dead button. - */ -function MoreOptionsStub() { - return ( - - ); -} - /** * Outer card for the built-in flow. Mirrors the legacy machine's shell * (logo, title, privacy footer) so onboarding stays visually coherent; the diff --git a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx index 044cb021..134bbaa7 100644 --- a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx +++ b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx @@ -806,13 +806,7 @@ describe('ModelCheckStep (builtin flow)', () => { .querySelector('[data-tier="fast"]') ?.getAttribute('data-recommended'), ).toBe('false'); - const stub = screen.getByRole('button', { - name: 'More options · Full model browser coming soon', - }); - expect(stub).toBeDisabled(); - expect( - screen.getByText('Use my existing Ollama instead'), - ).toBeInTheDocument(); + expect(screen.getByText('Use it instead')).toBeInTheDocument(); expect( screen.getByText( 'Private by default · All inference runs on your machine', @@ -826,9 +820,7 @@ describe('ModelCheckStep (builtin flow)', () => { renderBuiltin(); await act(async () => {}); - expect( - screen.queryByText('Use my existing Ollama instead'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Use it instead')).not.toBeInTheDocument(); }); it('one-tap download starts immediately (no confirm), walks to ready, refreshes, and advances', async () => { @@ -908,9 +900,7 @@ describe('ModelCheckStep (builtin flow)', () => { renderBuiltin(); await act(async () => {}); - expect( - screen.queryByText('Use my existing Ollama instead'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Use it instead')).not.toBeInTheDocument(); expect(screen.getByText('Model balanced')).toBeInTheDocument(); }); @@ -999,7 +989,7 @@ describe('ModelCheckStep (builtin flow)', () => { await act(async () => {}); await act(async () => { - fireEvent.click(screen.getByText('Use my existing Ollama instead')); + fireEvent.click(screen.getByText('Use it instead')); }); expect(invoke).toHaveBeenCalledWith('set_active_provider', { @@ -1032,7 +1022,7 @@ describe('ModelCheckStep (builtin flow)', () => { }); await act(async () => { - fireEvent.click(screen.getByText('Use my existing Ollama instead')); + fireEvent.click(screen.getByText('Use it instead')); }); expect(invoke).toHaveBeenCalledWith('cancel_model_download'); @@ -1057,9 +1047,7 @@ describe('ModelCheckStep (builtin flow)', () => { }); }); - expect( - screen.queryByText('Use my existing Ollama instead'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Use it instead')).not.toBeInTheDocument(); }); it('stays on the builtin flow when switching the provider fails', async () => { @@ -1074,7 +1062,7 @@ describe('ModelCheckStep (builtin flow)', () => { await act(async () => {}); await act(async () => { - fireEvent.click(screen.getByText('Use my existing Ollama instead')); + fireEvent.click(screen.getByText('Use it instead')); }); expect(screen.queryByLabelText('Verify setup')).not.toBeInTheDocument(); @@ -1097,9 +1085,7 @@ describe('ModelCheckStep (builtin flow)', () => { }); expect(screen.getByText("You're offline")).toBeInTheDocument(); - expect( - screen.getByText('Use my existing Ollama instead'), - ).toBeInTheDocument(); + expect(screen.getByText('Use it instead')).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Retry' })); From f0c877bdd7577e18ea1b419e0a8f22584a5b6d8f Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 13:25:59 -0500 Subject: [PATCH 19/51] feat: soften the Ollama escape-hatch copy Signed-off-by: Logan Nguyen --- src/components/StarterMatrix.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/StarterMatrix.tsx b/src/components/StarterMatrix.tsx index 4e805555..199043f3 100644 --- a/src/components/StarterMatrix.tsx +++ b/src/components/StarterMatrix.tsx @@ -207,7 +207,7 @@ export function StarterMatrix({ color: 'rgba(255,255,255,0.5)', }} > - Already running Ollama?{' '} + Looks like Ollama's also running here.{' '} + + ); + } + + const trailing = + status.etaSeconds !== null + ? `${status.percent}% · ${formatEta(status.etaSeconds)} left` + : `${status.percent}%`; + return ( + + Setting up your model + + ); +} diff --git a/src/components/StarterMatrix.tsx b/src/components/StarterMatrix.tsx index c3af668d..acecfb1c 100644 --- a/src/components/StarterMatrix.tsx +++ b/src/components/StarterMatrix.tsx @@ -16,9 +16,10 @@ import { useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import type React from 'react'; -import type { - DownloadUiFailKind, - DownloadUiState, +import { + isDownloadInFlight, + type DownloadUiFailKind, + type DownloadUiState, } from '../hooks/useDownloadModel'; import type { RamFit, StarterOption, StarterTier } from '../types/starter'; @@ -140,6 +141,12 @@ export interface StarterMatrixProps { onDiscard: (sha256: string) => void; onCancel: () => void; onRetry: () => void; + /** + * When wired, renders a quiet "Continue setup" line while a download is in + * flight, letting the user leave the picker and let it finish in the + * background. Omitted in the Settings context, where there is no next step. + */ + onContinue?: () => void; /** When true (and onUseOllama is wired), offers the Ollama escape hatch. */ ollamaDetected?: boolean; onUseOllama?: () => void; @@ -156,6 +163,7 @@ export function StarterMatrix({ onDiscard, onCancel, onRetry, + onContinue, ollamaDetected, onUseOllama, }: StarterMatrixProps) { @@ -208,6 +216,33 @@ export function StarterMatrix({ ); })} + {onContinue && isDownloadInFlight(state.phase) ? ( +
+ Downloading in the background.{' '} + +
+ ) : null} {ollamaDetected && onUseOllama ? (
{ + it('shows the setup label, percent and ETA while downloading', () => { + render( + , + ); + expect(screen.getByText('Setting up your model')).toBeInTheDocument(); + expect(screen.getByText('62% · 1m left')).toBeInTheDocument(); + }); + + it('omits the ETA when it is not yet measurable', () => { + render( + , + ); + expect(screen.getByText('5%')).toBeInTheDocument(); + }); + + it('formats hour-scale and second-scale ETAs', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('1% · 1h 1m left')).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText('99% · 30s left')).toBeInTheDocument(); + }); + + it('shows a ready state', () => { + render(); + expect(screen.getByText('Model ready')).toBeInTheDocument(); + }); + + it('shows a failure message with a Retry button', () => { + const onRetry = vi.fn(); + render( + , + ); + expect(screen.getByText('Download failed')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry download' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/__tests__/StarterMatrix.test.tsx b/src/components/__tests__/StarterMatrix.test.tsx index 4715ba82..0dc416f4 100644 --- a/src/components/__tests__/StarterMatrix.test.tsx +++ b/src/components/__tests__/StarterMatrix.test.tsx @@ -347,6 +347,72 @@ describe('StarterMatrix (picker)', () => { expect(screen.queryByText('Discard partial')).not.toBeInTheDocument(); }); + it('shows the Continue line while a download is in flight and fires onContinue', () => { + const onContinue = vi.fn(); + renderMatrix([makeOption('fast')], { + state: { phase: 'downloading' }, + downloadingTier: 'fast', + onContinue, + }); + expect( + screen.getByText('Downloading in the background.'), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Continue setup →' })); + expect(onContinue).toHaveBeenCalledTimes(1); + }); + + it('shows the Continue line through every in-flight phase', () => { + const phases: DownloadUiState['phase'][] = [ + 'downloading', + 'downloading_mmproj', + 'verifying', + 'installing', + 'warming_up', + ]; + for (const phase of phases) { + const { unmount } = renderMatrix([makeOption('fast')], { + state: { phase } as DownloadUiState, + downloadingTier: 'fast', + onContinue: vi.fn(), + }); + expect( + screen.getByText('Downloading in the background.'), + ).toBeInTheDocument(); + unmount(); + } + }); + + it('hides the Continue line outside the in-flight phases', () => { + const states: DownloadUiState[] = [ + { phase: 'idle' }, + { phase: 'confirming', tier: 'fast' }, + { phase: 'resume_pending' }, + { phase: 'ready' }, + { phase: 'failed', kind: 'other', message: 'x' }, + ]; + for (const state of states) { + const { unmount } = renderMatrix([makeOption('fast')], { + state, + downloadingTier: 'fast', + onContinue: vi.fn(), + }); + expect( + screen.queryByText('Downloading in the background.'), + ).not.toBeInTheDocument(); + unmount(); + } + }); + + it('hides the Continue line when onContinue is not wired', () => { + renderMatrix([makeOption('fast')], { + state: { phase: 'downloading' }, + downloadingTier: 'fast', + }); + expect( + screen.queryByText('Downloading in the background.'), + ).not.toBeInTheDocument(); + }); + it('shows the Ollama escape hatch only when detected and wired', () => { const onUseOllama = vi.fn(); const { rerender } = renderMatrix(THREE_TIERS, { diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx new file mode 100644 index 00000000..4107317b --- /dev/null +++ b/src/contexts/DownloadContext.tsx @@ -0,0 +1,135 @@ +/** + * App-root download context. + * + * Lifts the single starter-model download machine above the onboarding + * stage split so a download survives `ModelCheckStep` unmounting when the + * user taps "Continue" mid-download. The picker, the onboarding intro, and + * the ask bar all read one live download from here. + * + * It wraps `useDownloadModel` (engine handoff off: the engine starts lazily + * on the first chat, so `AllDone` is terminal at `ready`) and adds the bits + * the picker used to own locally: which tier is downloading, the resume-seed + * floor, the active option, and the card's grand total (weights + vision + * companion) the ambient strip needs to render percent. + */ + +import { + createContext, + use, + useCallback, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { + useDownloadModel, + type UseDownloadModel, +} from '../hooks/useDownloadModel'; +import type { StarterOption, StarterTier } from '../types/starter'; + +export interface DownloadContextValue extends UseDownloadModel { + /** Tier whose download is in flight; null when idle. */ + downloadingTier: StarterTier | null; + /** + * Bytes already on disk for a resumed download, flooring the bar at the + * paused position until the first real event lands. Null for a fresh + * (non-resume) download. + */ + resumeSeedBytes: number | null; + /** The option being downloaded; carries the grand total the strip needs. */ + activeOption: StarterOption | null; + /** + * The active option's full on-disk cost (weights + vision companion), or + * null when no download is active. + */ + grandTotalBytes: number | null; + /** + * Start a fresh download for a tier: clears the resume seed, records the + * tier + option, and kicks off the machine. + */ + beginDownload: (tier: StarterTier, option: StarterOption) => void; + /** + * Resume an interrupted download: floors the bar at `partialBytes`, records + * the tier + option, and restarts the machine. + */ + resumeDownload: ( + tier: StarterTier, + option: StarterOption, + partialBytes: number, + ) => void; +} + +const DownloadContext = createContext(null); + +export function DownloadProvider({ children }: { children: ReactNode }) { + const download = useDownloadModel(); + const [downloadingTier, setDownloadingTier] = useState( + null, + ); + const [resumeSeedBytes, setResumeSeedBytes] = useState(null); + const [activeOption, setActiveOption] = useState(null); + + const { start, resume } = download; + + const beginDownload = useCallback( + (tier: StarterTier, option: StarterOption) => { + setResumeSeedBytes(null); + setDownloadingTier(tier); + setActiveOption(option); + void start(tier); + }, + [start], + ); + + const resumeDownload = useCallback( + (tier: StarterTier, option: StarterOption, partialBytes: number) => { + setResumeSeedBytes(partialBytes); + setDownloadingTier(tier); + setActiveOption(option); + void resume(tier); + }, + [resume], + ); + + const grandTotalBytes = + activeOption === null + ? null + : activeOption.starter.size_bytes + activeOption.starter.mmproj_bytes; + + const value = useMemo( + () => ({ + ...download, + downloadingTier, + resumeSeedBytes, + activeOption, + grandTotalBytes, + beginDownload, + resumeDownload, + }), + [ + download, + downloadingTier, + resumeSeedBytes, + activeOption, + grandTotalBytes, + beginDownload, + resumeDownload, + ], + ); + + return {children}; +} + +/** + * Returns the app-root download machine. Throws when no `DownloadProvider` + * wraps the caller: unlike config, there is no sensible static fallback for + * a live download, so a missing provider is a wiring bug, not a test + * convenience. + */ +export function useDownloadCtx(): DownloadContextValue { + const value = use(DownloadContext); + if (value === null) { + throw new Error('useDownloadCtx must be used within a DownloadProvider'); + } + return value; +} diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx new file mode 100644 index 00000000..1918ebe2 --- /dev/null +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -0,0 +1,119 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { DownloadProvider, useDownloadCtx } from '../DownloadContext'; +import { + invoke, + enableChannelCapture, + resetChannelCapture, + clearEventHandlers, +} from '../../testUtils/mocks/tauri'; +import type { StarterOption } from '../../types/starter'; + +function option( + overrides: Partial = {}, +): StarterOption { + return { + starter: { + tier: 'balanced', + display_name: 'Balanced', + repo: 'acme/balanced', + revision: 'rev', + file_name: 'weights.gguf', + sha256: 'sha', + size_bytes: 8_000_000_000, + quant: 'Q4_K_M', + vision: true, + thinking: false, + mmproj_file: 'mmproj.gguf', + mmproj_sha256: 'mmsha', + mmproj_bytes: 2_000_000_000, + est_runtime_gb: 10, + license_note: 'MIT', + origin: 'Acme', + origin_repo: 'acme/origin', + ...overrides, + }, + fit: 'fits', + installed: false, + partial_bytes: null, + }; +} + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('DownloadContext', () => { + beforeEach(() => { + invoke.mockReset(); + enableChannelCapture(); + }); + + afterEach(() => { + resetChannelCapture(); + clearEventHandlers(); + vi.restoreAllMocks(); + }); + + it('throws when useDownloadCtx is called outside a provider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useDownloadCtx())).toThrow( + 'useDownloadCtx must be used within a DownloadProvider', + ); + spy.mockRestore(); + }); + + it('exposes the idle download machine with no active download', () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + expect(result.current.state).toEqual({ phase: 'idle' }); + expect(result.current.combinedBytes).toBeNull(); + expect(result.current.downloadingTier).toBeNull(); + expect(result.current.resumeSeedBytes).toBeNull(); + expect(result.current.activeOption).toBeNull(); + expect(result.current.grandTotalBytes).toBeNull(); + }); + + it('beginDownload records the tier, option, grand total and starts the machine', async () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + const opt = option(); + + await act(async () => { + result.current.beginDownload('balanced', opt); + }); + + expect(result.current.downloadingTier).toBe('balanced'); + expect(result.current.activeOption).toBe(opt); + expect(result.current.resumeSeedBytes).toBeNull(); + // Grand total is weights + vision companion summed. + expect(result.current.grandTotalBytes).toBe(10_000_000_000); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenCalledWith('download_starter', { + tier: 'balanced', + onEvent: expect.anything(), + }); + }); + + it('resumeDownload floors the bar at the partial bytes and restarts the machine', async () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + const opt = option({ + tier: 'fast', + size_bytes: 4_000_000_000, + mmproj_bytes: 0, + }); + + await act(async () => { + result.current.resumeDownload('fast', opt, 3_000_000_000); + }); + + expect(result.current.downloadingTier).toBe('fast'); + expect(result.current.activeOption).toBe(opt); + expect(result.current.resumeSeedBytes).toBe(3_000_000_000); + expect(result.current.grandTotalBytes).toBe(4_000_000_000); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenCalledWith('download_starter', { + tier: 'fast', + onEvent: expect.anything(), + }); + }); +}); diff --git a/src/hooks/__tests__/useDownloadModel.test.tsx b/src/hooks/__tests__/useDownloadModel.test.tsx index 0395f6b5..6831bd54 100644 --- a/src/hooks/__tests__/useDownloadModel.test.tsx +++ b/src/hooks/__tests__/useDownloadModel.test.tsx @@ -3,8 +3,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { computeEtaSeconds, computeSpeedBytesPerSec, + isDownloadInFlight, useDownloadModel, } from '../useDownloadModel'; +import type { DownloadUiState } from '../useDownloadModel'; import { invoke, getLastChannel, @@ -710,3 +712,31 @@ describe('computeEtaSeconds', () => { expect(computeEtaSeconds(samples, 150, 100)).toBe(0); }); }); + +describe('isDownloadInFlight', () => { + it('is true while bytes move and through the post-download steps', () => { + const inFlight: DownloadUiState['phase'][] = [ + 'downloading', + 'downloading_mmproj', + 'verifying', + 'installing', + 'warming_up', + ]; + for (const phase of inFlight) { + expect(isDownloadInFlight(phase)).toBe(true); + } + }); + + it('is false for the idle, pre-flight and terminal phases', () => { + const settled: DownloadUiState['phase'][] = [ + 'idle', + 'confirming', + 'resume_pending', + 'ready', + 'failed', + ]; + for (const phase of settled) { + expect(isDownloadInFlight(phase)).toBe(false); + } + }); +}); diff --git a/src/hooks/useDownloadModel.ts b/src/hooks/useDownloadModel.ts index b5cf3b92..be461823 100644 --- a/src/hooks/useDownloadModel.ts +++ b/src/hooks/useDownloadModel.ts @@ -46,6 +46,23 @@ export type DownloadUiState = | { phase: 'resume_pending' } | { phase: 'failed'; kind: DownloadUiFailKind; message: string }; +/** + * True while a download is active but not yet terminal: bytes still moving + * (`downloading`/`downloading_mmproj`) or the post-download verify/install/warm + * steps running. False for idle, the pre-flight confirm/resume states, and the + * terminal `ready`/`failed`. Shared by the picker's "Continue setup" line, the + * ambient strip, and the submit soft-block so all three agree on "in flight". + */ +export function isDownloadInFlight(phase: DownloadUiState['phase']): boolean { + return ( + phase === 'downloading' || + phase === 'downloading_mmproj' || + phase === 'verifying' || + phase === 'installing' || + phase === 'warming_up' + ); +} + /** Last reported byte counts for the file currently downloading. */ export interface DownloadProgressInfo { file: string; diff --git a/src/main.tsx b/src/main.tsx index 33a2ede4..f93d7a7c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import App from './App'; import { ConfigProvider } from './contexts/ConfigContext'; +import { DownloadProvider } from './contexts/DownloadContext'; import { SettingsWindow } from './settings/SettingsWindow'; import { UpdateWindow } from './view/update/UpdateWindow'; @@ -44,7 +45,9 @@ export function rootForLabel(label: string): React.ReactElement { return ( - + + + ); diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx index c9dbd727..446778c0 100644 --- a/src/view/AskBarView.tsx +++ b/src/view/AskBarView.tsx @@ -10,6 +10,8 @@ import { CommandSuggestion } from '../components/CommandSuggestion'; import { ModelPicker } from '../components/ModelPicker'; import { Tooltip } from '../components/Tooltip'; import { CapabilityMismatchStrip } from '../components/CapabilityMismatchStrip'; +import { DownloadStatusStrip } from '../components/DownloadStatusStrip'; +import type { DownloadStripStatus } from '../components/DownloadStatusStrip'; import type { CapabilityMismatchMessage } from '../components/CapabilityMismatchStrip'; import type { AttachedImage } from '../types/image'; import { MAX_IMAGE_SIZE_BYTES } from '../types/image'; @@ -204,6 +206,13 @@ interface AskBarViewProps { * `{ text, url }` pair (clickable strip that opens the URL). */ capabilityConflictMessage?: CapabilityMismatchMessage | null; + /** + * Ambient model-download status to render in the same slot as the + * capability strip. `null` (or undefined) renders nothing. Set by the host + * while a built-in model is downloading in the background so the ask bar + * shows progress, readiness, or a retry without leaving the picker. + */ + downloadStatus?: DownloadStripStatus | null; /** * When true, the input row plays a brief horizontal shake animation. * The host pulses this true / false to signal a refused submit. @@ -245,6 +254,7 @@ export function AskBarView({ onModelPickerToggle, isModelPickerOpen, capabilityConflictMessage, + downloadStatus, shake = false, maxImages, onFirstKeystroke, @@ -526,6 +536,7 @@ export function AskBarView({ {capabilityConflictMessage && ( )} + {downloadStatus ? : null} {/* Command suggestion renders above the input row in the normal DOM flow. Being inside the morphing container means the ResizeObserver detects the added height and grows the native window upward to reveal diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx index c8cdd9f1..57632906 100644 --- a/src/view/__tests__/AskBarView.test.tsx +++ b/src/view/__tests__/AskBarView.test.tsx @@ -102,6 +102,43 @@ describe('AskBarView', () => { expect(screen.getByText('Reply...')).toBeInTheDocument(); }); + it('renders the ambient download strip when a download status is supplied', () => { + render( + , + ); + expect(screen.getByTestId('download-status-strip')).toBeInTheDocument(); + expect(screen.getByText('Setting up your model')).toBeInTheDocument(); + }); + + it('renders no download strip when no download status is supplied', () => { + render( + , + ); + expect( + screen.queryByTestId('download-status-strip'), + ).not.toBeInTheDocument(); + }); + it('calls setQuery when the editor text changes', async () => { const setQuery = vi.fn(); render( diff --git a/src/view/onboarding/ModelCheckStep.tsx b/src/view/onboarding/ModelCheckStep.tsx index b00e461e..1e448b6d 100644 --- a/src/view/onboarding/ModelCheckStep.tsx +++ b/src/view/onboarding/ModelCheckStep.tsx @@ -22,14 +22,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import thukiLogo from '../../../src-tauri/icons/128x128.png'; import { useConfig } from '../../contexts/ConfigContext'; +import { useDownloadCtx } from '../../contexts/DownloadContext'; import { FIT_COPY, useStarterOptions } from '../../components/StarterPicker'; import { StarterMatrix } from '../../components/StarterMatrix'; import type { ConfirmInfo } from '../../components/DownloadProgress'; -import { - useDownloadModel, - type DownloadUiState, -} from '../../hooks/useDownloadModel'; -import type { StarterOption, StarterTier } from '../../types/starter'; +import type { DownloadUiState } from '../../hooks/useDownloadModel'; +import type { StarterOption } from '../../types/starter'; import { Badge } from './_shared'; const OLLAMA_DOCS_URL = 'https://ollama.com/download'; @@ -178,10 +176,12 @@ export function buildConfirmInfo( * - `detect_ollama`: gates the "Use my existing Ollama instead" hatch. * - `get_models_dir_free_bytes`: feeds the confirm card's disk line. * - * Download lifecycle is owned by `useDownloadModel` (awaitEngine off: the - * engine starts lazily on first chat, so `AllDone` is terminal here). On - * `ready` the options refresh (so the row shows Installed) and the backend - * advances onboarding to the intro step. + * Download lifecycle is owned by the app-root `DownloadProvider` (engine + * handoff off: the engine starts lazily on first chat, so `AllDone` is + * terminal here), consumed via `useDownloadCtx` so a download started here + * survives this step unmounting when the user taps "Continue". On `ready` + * the options refresh (so the row shows Installed) and the backend advances + * onboarding to the intro step. */ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { const { options, refresh } = useStarterOptions(); @@ -189,24 +189,17 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { state, combinedBytes, speedBytesPerSec, - start, + // The tier whose download is in flight and the resume-seed floor both live + // in the provider now, so the bar keeps rendering after this step unmounts. + downloadingTier, + resumeSeedBytes, cancel, retry, - resume, discard, enterResumePending, - } = useDownloadModel(); - // The tier whose download is in flight; the matrix renders the fill there - // and dims the rest. The matrix only reads it while a download is busy, so - // the last value lingering after one finishes or cancels is harmless. - const [downloadingTier, setDownloadingTier] = useState( - null, - ); - // On Resume the download restarts with combinedBytes null until the first - // event arrives, which would flash the fill back to "Starting…". Seeding the - // partial byte count here floors the combined bar at the paused position - // until the first real event lands. Null for a fresh (non-resume) download. - const [resumeSeedBytes, setResumeSeedBytes] = useState(null); + beginDownload, + resumeDownload, + } = useDownloadCtx(); const [ollamaDetected, setOllamaDetected] = useState(false); useEffect(() => { @@ -300,23 +293,19 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { speedBytesPerSec={speedBytesPerSec} downloadingTier={downloadingTier} onDownload={(tier) => { - setResumeSeedBytes(null); - setDownloadingTier(tier); - void start(tier); + const option = options.find((o) => o.starter.tier === tier)!; + beginDownload(tier, option); }} onResume={(tier, partialBytes) => { - // Floor the combined bar at the bytes already on disk so it stays - // at the paused position instead of flashing to "Starting…" when - // the resumed download restarts. - setResumeSeedBytes(partialBytes); - setDownloadingTier(tier); - void resume(tier); + const option = options.find((o) => o.starter.tier === tier)!; + resumeDownload(tier, option, partialBytes); }} onDiscard={(sha256) => { void discard(sha256).then(refresh); }} onCancel={() => void cancel()} onRetry={() => void retry()} + onContinue={() => void invoke('advance_past_model_check')} ollamaDetected={ollamaDetected} onUseOllama={() => void handleUseOllama()} /> diff --git a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx index 134bbaa7..729c6dfc 100644 --- a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx +++ b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx @@ -14,6 +14,7 @@ import { DEFAULT_CONFIG, type AppConfig, } from '../../../contexts/ConfigContext'; +import { DownloadProvider } from '../../../contexts/DownloadContext'; import { invoke, enableChannelCaptureWithResponses, @@ -769,7 +770,9 @@ function builtinResponses(overrides: Record = {}) { function renderBuiltin() { return render( - + + + , ); } @@ -860,6 +863,27 @@ describe('ModelCheckStep (builtin flow)', () => { ).toHaveLength(2); }); + it('Continue line advances onboarding while the download keeps running', async () => { + builtinResponses({ advance_past_model_check: undefined }); + + const { container } = renderBuiltin(); + await act(async () => {}); + await startDownload(container as HTMLElement, 'balanced'); + + const channel = getLastChannel()!; + await act(async () => { + channel.simulateMessage({ + type: 'Started', + data: { file: 'balanced.gguf', total_bytes: 100, resumed_from: 0 }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Continue setup →' })); + }); + expect(invoke).toHaveBeenCalledWith('advance_past_model_check'); + }); + it('advances immediately when check_model_setup already reports ready', async () => { builtinResponses({ check_model_setup: READY_RESPONSE, From 4c1804e5c1d3fbf1c666be777c968ebb23de6990 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 19:01:25 -0500 Subject: [PATCH 24/51] fix: drop download speed from the picker bar so the label fits the column Signed-off-by: Logan Nguyen --- src/components/StarterMatrix.tsx | 22 +++++++++---------- .../__tests__/StarterMatrix.test.tsx | 18 +++++++-------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/components/StarterMatrix.tsx b/src/components/StarterMatrix.tsx index acecfb1c..015947e5 100644 --- a/src/components/StarterMatrix.tsx +++ b/src/components/StarterMatrix.tsx @@ -94,11 +94,6 @@ function gb(bytes: number): string { return (bytes / 1e9).toFixed(1); } -/** Bytes/sec rendered as decimal megabytes per second (e.g. "8.2"). */ -function mbps(bytesPerSec: number): string { - return (bytesPerSec / 1e6).toFixed(1); -} - /** Seconds rendered as a compact countdown: "45s", "5m", "2h 1m". */ function formatEta(etaSeconds: number): string { if (etaSeconds < 60) return `${etaSeconds}s`; @@ -609,14 +604,16 @@ function DownloadCell({ // While bytes are coming down, the button IS the progress: it fills as one // continuous bar against the card's full total (weights + vision companion - // summed, never two separate downloads), shows the byte counts, speed and - // ETA inside (no percentage), and is the cancel control. Hovering eases the - // warm fill to a neutral "stop" grey and swaps in "Pause download". + // summed, never two separate downloads), shows the byte counts and ETA + // inside (no percentage, no speed), and is the cancel control. Hovering eases + // the warm fill to a neutral "stop" grey and swaps in "Pause download". if (state.phase === 'downloading' || state.phase === 'downloading_mmproj') { const pct = combinedBytes !== null && grandTotalBytes > 0 ? Math.min(100, Math.floor((combinedBytes / grandTotalBytes) * 100)) : 0; + // The rolling rate drives the ETA but is not shown: the ETA already answers + // "how much longer", and the column is too narrow for a third figure. // speedBytesPerSec is null or strictly positive (the hook never reports a // zero rate), so a non-null value is always safe to divide by. const etaSeconds = @@ -630,8 +627,8 @@ function DownloadCell({ combinedBytes === null ? 'Starting…' : `${gb(combinedBytes)} / ${gb(grandTotalBytes)} GB${ - speedBytesPerSec !== null ? ` · ${mbps(speedBytesPerSec)} MB/s` : '' - }${etaSeconds !== null ? ` · ${formatEta(etaSeconds)} left` : ''}`; + etaSeconds !== null ? ` · ${formatEta(etaSeconds)} left` : '' + }`; return ( + ); +} + +/** + * Borderless shell: a top progress edge filled to `percent` plus the row. No + * box or tint of its own, so it inherits the surface behind it. + */ function Shell({ color, - tint, + fill, + percent, children, }: { color: string; - tint: string; + fill: string; + percent: number; children: React.ReactNode; }) { return ( @@ -50,15 +101,23 @@ function Shell({ role="status" aria-live="polite" data-testid="download-status-strip" - className={baseClass} - style={{ - background: `${tint}1a`, - borderColor: `${tint}4d`, - color: 'var(--color-text-primary, #f0f0f2)', - }} + className="mx-4 mt-2 mb-0" + style={{ color: 'var(--color-text-primary, #f0f0f2)' }} > - - {children} +
); } @@ -70,7 +129,7 @@ export function DownloadStatusStrip({ }) { if (status.kind === 'ready') { return ( - + Model ready ); @@ -78,21 +137,34 @@ export function DownloadStatusStrip({ if (status.kind === 'failed') { return ( - + {status.message} - + /> + + ); + } + + if (status.kind === 'paused') { + return ( + + Paused · {status.percent}% + + ); } @@ -102,22 +174,16 @@ export function DownloadStatusStrip({ ? `${status.percent}% · ${formatEta(status.etaSeconds)} left` : `${status.percent}%`; return ( - + Setting up your model - ); } diff --git a/src/components/__tests__/DownloadStatusStrip.test.tsx b/src/components/__tests__/DownloadStatusStrip.test.tsx index f5b3eeae..b9d7372a 100644 --- a/src/components/__tests__/DownloadStatusStrip.test.tsx +++ b/src/components/__tests__/DownloadStatusStrip.test.tsx @@ -6,7 +6,12 @@ describe('DownloadStatusStrip', () => { it('shows the setup label, percent and ETA while downloading', () => { render( , ); expect(screen.getByText('Setting up your model')).toBeInTheDocument(); @@ -16,7 +21,12 @@ describe('DownloadStatusStrip', () => { it('omits the ETA when it is not yet measurable', () => { render( , ); expect(screen.getByText('5%')).toBeInTheDocument(); @@ -25,18 +35,54 @@ describe('DownloadStatusStrip', () => { it('formats hour-scale and second-scale ETAs', () => { const { rerender } = render( , ); expect(screen.getByText('1% · 1h 1m left')).toBeInTheDocument(); rerender( , ); expect(screen.getByText('99% · 30s left')).toBeInTheDocument(); }); + it('pauses the download from the downloading state', () => { + const onPause = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Pause download' })); + expect(onPause).toHaveBeenCalledTimes(1); + }); + + it('shows a paused state with Resume and Discard', () => { + const onResume = vi.fn(); + const onDiscard = vi.fn(); + render( + , + ); + expect(screen.getByText('Paused · 58%')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Resume download' })); + expect(onResume).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Discard download' })); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + it('shows a ready state', () => { render(); expect(screen.getByText('Model ready')).toBeInTheDocument(); diff --git a/src/components/__tests__/StarterMatrix.test.tsx b/src/components/__tests__/StarterMatrix.test.tsx index 440eefe4..b1c27991 100644 --- a/src/components/__tests__/StarterMatrix.test.tsx +++ b/src/components/__tests__/StarterMatrix.test.tsx @@ -249,9 +249,7 @@ describe('StarterMatrix (picker)', () => { downloadingTier: 'fast', }); // 3.3e9 / 2e5 = 16500s -> 4h 35m (speed feeds the ETA, but is not shown). - expect( - screen.getByText('0.0 / 3.3 GB · 4h 35m left'), - ).toBeInTheDocument(); + expect(screen.getByText('0.0 / 3.3 GB · 4h 35m left')).toBeInTheDocument(); }); it('shows "Starting…" before the first combined byte arrives', () => { diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index 4107317b..d430af4e 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -57,6 +57,16 @@ export interface DownloadContextValue extends UseDownloadModel { option: StarterOption, partialBytes: number, ) => void; + /** True while a started download has been paused (cancelled, partial kept). */ + isPaused: boolean; + /** Bytes downloaded at the moment of pause, for the paused strip's percent. */ + pausedBytes: number; + /** Pause the in-flight download: cancel it; the partial stays on disk. */ + pauseDownload: () => void; + /** Resume a paused download from where it stopped. */ + resumeFromPause: () => void; + /** Discard a paused download's partial and clear the active option. */ + discardActive: () => void; } const DownloadContext = createContext(null); @@ -68,14 +78,17 @@ export function DownloadProvider({ children }: { children: ReactNode }) { ); const [resumeSeedBytes, setResumeSeedBytes] = useState(null); const [activeOption, setActiveOption] = useState(null); + const [isPaused, setIsPaused] = useState(false); + const [pausedBytes, setPausedBytes] = useState(0); - const { start, resume } = download; + const { start, resume, cancel, discard, combinedBytes } = download; const beginDownload = useCallback( (tier: StarterTier, option: StarterOption) => { setResumeSeedBytes(null); setDownloadingTier(tier); setActiveOption(option); + setIsPaused(false); void start(tier); }, [start], @@ -86,11 +99,33 @@ export function DownloadProvider({ children }: { children: ReactNode }) { setResumeSeedBytes(partialBytes); setDownloadingTier(tier); setActiveOption(option); + setIsPaused(false); void resume(tier); }, [resume], ); + const pauseDownload = useCallback(() => { + // Remember how far we got so the paused strip can show the percent, then + // cancel the run (the backend keeps the partial on disk for resume). + setPausedBytes(combinedBytes ?? 0); + setIsPaused(true); + void cancel(); + }, [combinedBytes, cancel]); + + const resumeFromPause = useCallback(() => { + // Only reachable from the paused strip, which renders only when a download + // was started, so the active option is always set here. + setIsPaused(false); + resumeDownload(activeOption!.starter.tier, activeOption!, pausedBytes); + }, [activeOption, pausedBytes, resumeDownload]); + + const discardActive = useCallback(() => { + setIsPaused(false); + void discard(activeOption!.starter.sha256); + setActiveOption(null); + }, [activeOption, discard]); + const grandTotalBytes = activeOption === null ? null @@ -105,6 +140,11 @@ export function DownloadProvider({ children }: { children: ReactNode }) { grandTotalBytes, beginDownload, resumeDownload, + isPaused, + pausedBytes, + pauseDownload, + resumeFromPause, + discardActive, }), [ download, @@ -114,6 +154,11 @@ export function DownloadProvider({ children }: { children: ReactNode }) { grandTotalBytes, beginDownload, resumeDownload, + isPaused, + pausedBytes, + pauseDownload, + resumeFromPause, + discardActive, ], ); diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx index 1918ebe2..a192198d 100644 --- a/src/contexts/__tests__/DownloadContext.test.tsx +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -5,10 +5,17 @@ import { DownloadProvider, useDownloadCtx } from '../DownloadContext'; import { invoke, enableChannelCapture, + getLastChannel, resetChannelCapture, clearEventHandlers, + type Channel, } from '../../testUtils/mocks/tauri'; -import type { StarterOption } from '../../types/starter'; +import type { DownloadEvent, StarterOption } from '../../types/starter'; + +/** The captured download channel, typed for simulateMessage calls. */ +function channel(): Channel { + return getLastChannel() as Channel; +} function option( overrides: Partial = {}, @@ -116,4 +123,90 @@ describe('DownloadContext', () => { onEvent: expect.anything(), }); }); + + it('pauseDownload remembers the bytes so far and cancels the run', async () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + const opt = option(); + + await act(async () => { + result.current.beginDownload('balanced', opt); + }); + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'weights.gguf', total_bytes: 100, resumed_from: 0 }, + }), + ); + act(() => + channel().simulateMessage({ + type: 'Progress', + data: { file: 'weights.gguf', bytes: 60, total_bytes: 100 }, + }), + ); + + await act(async () => { + result.current.pauseDownload(); + }); + + expect(result.current.isPaused).toBe(true); + expect(result.current.pausedBytes).toBe(60); + expect(invoke).toHaveBeenCalledWith('cancel_model_download'); + }); + + it('pauseDownload defaults to zero bytes before the first event arrives', async () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + + await act(async () => { + result.current.beginDownload('balanced', option()); + }); + await act(async () => { + result.current.pauseDownload(); + }); + + expect(result.current.isPaused).toBe(true); + expect(result.current.pausedBytes).toBe(0); + }); + + it('resumeFromPause restarts the download and clears the paused flag', async () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + const opt = option(); + + await act(async () => { + result.current.beginDownload('balanced', opt); + }); + await act(async () => { + result.current.pauseDownload(); + }); + await act(async () => { + result.current.resumeFromPause(); + }); + + expect(result.current.isPaused).toBe(false); + expect(result.current.downloadingTier).toBe('balanced'); + expect( + invoke.mock.calls.filter((c) => c[0] === 'download_starter'), + ).toHaveLength(2); + }); + + it('discardActive discards the partial and clears the active option', async () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + const opt = option({ sha256: 'deadbeef' }); + + await act(async () => { + result.current.beginDownload('balanced', opt); + }); + await act(async () => { + result.current.pauseDownload(); + }); + await act(async () => { + result.current.discardActive(); + }); + + expect(result.current.isPaused).toBe(false); + expect(result.current.activeOption).toBeNull(); + expect(result.current.grandTotalBytes).toBeNull(); + expect(invoke).toHaveBeenCalledWith('discard_partial_download', { + sha256: 'deadbeef', + }); + }); }); diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx index 446778c0..be367f71 100644 --- a/src/view/AskBarView.tsx +++ b/src/view/AskBarView.tsx @@ -264,8 +264,15 @@ export function AskBarView({ /** True when the UI should be locked - either generating or waiting for images. */ const isBusy = isGenerating || isSubmitPending; + // A built-in model still downloading (or paused mid-download) holds the + // submit (App soft-blocks it), so the send affordance is greyed to match: + // the input stays editable for drafting, but there is nothing to send yet. + const isDownloadHolding = + downloadStatus?.kind === 'downloading' || downloadStatus?.kind === 'paused'; const canSubmit = - (query.trim().length > 0 || attachedImages.length > 0) && !isBusy; + (query.trim().length > 0 || attachedImages.length > 0) && + !isBusy && + !isDownloadHolding; const isAtMaxImages = attachedImages.length >= maxImages; /** True briefly after a paste attempt is rejected because max images reached. */ diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx index 57632906..9369d882 100644 --- a/src/view/__tests__/AskBarView.test.tsx +++ b/src/view/__tests__/AskBarView.test.tsx @@ -113,7 +113,12 @@ describe('AskBarView', () => { onSubmit={vi.fn()} onCancel={vi.fn()} inputRef={makeRef()} - downloadStatus={{ kind: 'downloading', percent: 40, etaSeconds: 90 }} + downloadStatus={{ + kind: 'downloading', + percent: 40, + etaSeconds: 90, + onPause: vi.fn(), + }} />, ); expect(screen.getByTestId('download-status-strip')).toBeInTheDocument(); @@ -139,6 +144,69 @@ describe('AskBarView', () => { ).not.toBeInTheDocument(); }); + it('disables the send button while a model is downloading, even with text typed', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + }); + + it('keeps the send button disabled while a download is paused', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + }); + + it('keeps the send button enabled once the download is ready', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: 'Send message' }), + ).not.toBeDisabled(); + }); + it('calls setQuery when the editor text changes', async () => { const setQuery = vi.fn(); render( From 7b31f5dddb123a915fcefcd503c7119ae94f31c1 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 19:59:37 -0500 Subject: [PATCH 26/51] fix: render the download strip inside the intro card instead of a detached overlay Signed-off-by: Logan Nguyen --- src/App.tsx | 54 ++++--------------- src/view/onboarding/IntroStep.tsx | 22 +++++++- .../onboarding/__tests__/IntroStep.test.tsx | 23 ++++++++ src/view/onboarding/index.tsx | 9 +++- 4 files changed, 61 insertions(+), 47 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 79c18dbd..d63c4924 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,10 +24,7 @@ import { useModelSelection } from './hooks/useModelSelection'; import { useModelCapabilities } from './hooks/useModelCapabilities'; import { useDownloadCtx } from './contexts/DownloadContext'; import { isDownloadInFlight } from './hooks/useDownloadModel'; -import { - DownloadStatusStrip, - type DownloadStripStatus, -} from './components/DownloadStatusStrip'; +import type { DownloadStripStatus } from './components/DownloadStatusStrip'; import { getCapabilityConflict, getEnvironmentMessage, @@ -3364,47 +3361,16 @@ function App() { // panel loses key focus and rAF is throttled. if (onboardingStage !== null) { + // The ambient download strip is rendered INSIDE the intro card (via + // OnboardingView -> IntroStep) so it reads as part of that screen, not a + // detached floating box. Not shown during model_check (the picker matrix + // has its own bar). return ( - <> - setOnboardingStage(null)} - /> - {/* Ambient download strip over the intro tour: IntroStep is a - self-contained full-screen modal with no footer, so the strip - floats at the bottom while the background download finishes. The - strip is borderless (it inherits its surface), so the floating - container supplies the ask-bar-style surface here. Not shown during - model_check (the matrix's own bar covers it). */} - {onboardingStage === 'intro' && downloadStripStatus ? ( -
-
- -
-
- ) : null} - + setOnboardingStage(null)} + downloadStatus={downloadStripStatus} + /> ); } diff --git a/src/view/onboarding/IntroStep.tsx b/src/view/onboarding/IntroStep.tsx index de80628b..256b2f9a 100644 --- a/src/view/onboarding/IntroStep.tsx +++ b/src/view/onboarding/IntroStep.tsx @@ -1,12 +1,22 @@ import { motion } from 'framer-motion'; import { invoke } from '@tauri-apps/api/core'; import thukiLogo from '../../../src-tauri/icons/128x128.png'; +import { + DownloadStatusStrip, + type DownloadStripStatus, +} from '../../components/DownloadStatusStrip'; interface Props { onComplete: () => void; + /** + * Ambient background-download status, rendered inside the card at its base + * while a built-in model finishes downloading during the tour. `null` / + * omitted renders nothing. + */ + downloadStatus?: DownloadStripStatus | null; } -export function IntroStep({ onComplete }: Props) { +export function IntroStep({ onComplete, downloadStatus }: Props) { const handleGetStarted = async () => { await invoke('finish_onboarding'); onComplete(); @@ -166,6 +176,16 @@ export function IntroStep({ onComplete }: Props) { > Private by default · All inference runs on your machine

+ + {/* Ambient download strip, rendered inside the card so it reads as part + of the screen. The borderless strip inherits the card surface; the + negative side margins pull it out to the content width (matching the + divider + CTA) so it spans cleanly rather than sitting inset. */} + {downloadStatus ? ( +
+ +
+ ) : null} ); diff --git a/src/view/onboarding/__tests__/IntroStep.test.tsx b/src/view/onboarding/__tests__/IntroStep.test.tsx index 80b2a752..5ce34361 100644 --- a/src/view/onboarding/__tests__/IntroStep.test.tsx +++ b/src/view/onboarding/__tests__/IntroStep.test.tsx @@ -54,6 +54,29 @@ describe('IntroStep', () => { expect(screen.getByText(/private by default/i)).toBeInTheDocument(); }); + it('renders the ambient download strip inside the card when a status is supplied', () => { + render( + , + ); + expect(screen.getByTestId('download-status-strip')).toBeInTheDocument(); + expect(screen.getByText('Setting up your model')).toBeInTheDocument(); + }); + + it('renders no download strip when no status is supplied', () => { + render(); + expect( + screen.queryByTestId('download-status-strip'), + ).not.toBeInTheDocument(); + }); + it('calls finish_onboarding and onComplete when Get Started is clicked', async () => { const onComplete = vi.fn(); invoke.mockResolvedValue(undefined); diff --git a/src/view/onboarding/index.tsx b/src/view/onboarding/index.tsx index c5a20042..a5b1e8da 100644 --- a/src/view/onboarding/index.tsx +++ b/src/view/onboarding/index.tsx @@ -1,6 +1,7 @@ import { IntroStep } from './IntroStep'; import { ModelCheckStep } from './ModelCheckStep'; import { PermissionsStep } from './PermissionsStep'; +import type { DownloadStripStatus } from '../../components/DownloadStatusStrip'; /** * Stage values mirror the Rust `OnboardingStage` enum exactly. The @@ -12,6 +13,8 @@ export type OnboardingStage = 'permissions' | 'model_check' | 'intro'; interface Props { stage: OnboardingStage; onComplete: () => void; + /** Ambient download status shown inside the intro card (intro stage only). */ + downloadStatus?: DownloadStripStatus | null; } /** @@ -25,9 +28,11 @@ interface Props { * When stage is "complete" the backend never emits the onboarding event, * so this component is never rendered. */ -export function OnboardingView({ stage, onComplete }: Props) { +export function OnboardingView({ stage, onComplete, downloadStatus }: Props) { if (stage === 'intro') { - return ; + return ( + + ); } if (stage === 'model_check') { // ModelCheckStep advances to `intro` via the backend From 2c34232421e769dfca6d71074a310b21508c62f5 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 20:17:16 -0500 Subject: [PATCH 27/51] fix: defer the paused state until the cancel lands so resume cannot race the download slot Signed-off-by: Logan Nguyen --- src/contexts/DownloadContext.tsx | 25 +++++++++++++------ .../__tests__/DownloadContext.test.tsx | 13 +++++++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index d430af4e..ac04b537 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -78,17 +78,25 @@ export function DownloadProvider({ children }: { children: ReactNode }) { ); const [resumeSeedBytes, setResumeSeedBytes] = useState(null); const [activeOption, setActiveOption] = useState(null); - const [isPaused, setIsPaused] = useState(false); + const [pauseRequested, setPauseRequested] = useState(false); const [pausedBytes, setPausedBytes] = useState(0); const { start, resume, cancel, discard, combinedBytes } = download; + const downloadPhase = download.state.phase; + + // A pause is only *committed* once the cancel has fully landed (machine back + // to idle, single download slot released). Deriving it rather than flipping a + // flag in pauseDownload means the strip offers Resume only after the slot is + // free, so a resume can never collide with the download it replaces and fail + // with "a download is already in progress". + const isPaused = pauseRequested && downloadPhase === 'idle'; const beginDownload = useCallback( (tier: StarterTier, option: StarterOption) => { setResumeSeedBytes(null); setDownloadingTier(tier); setActiveOption(option); - setIsPaused(false); + setPauseRequested(false); void start(tier); }, [start], @@ -99,7 +107,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { setResumeSeedBytes(partialBytes); setDownloadingTier(tier); setActiveOption(option); - setIsPaused(false); + setPauseRequested(false); void resume(tier); }, [resume], @@ -107,21 +115,22 @@ export function DownloadProvider({ children }: { children: ReactNode }) { const pauseDownload = useCallback(() => { // Remember how far we got so the paused strip can show the percent, then - // cancel the run (the backend keeps the partial on disk for resume). + // cancel the run (the backend keeps the partial on disk for resume). The + // pause only *shows* once `downloadPhase` reaches idle (see `isPaused`). setPausedBytes(combinedBytes ?? 0); - setIsPaused(true); + setPauseRequested(true); void cancel(); }, [combinedBytes, cancel]); const resumeFromPause = useCallback(() => { // Only reachable from the paused strip, which renders only when a download - // was started, so the active option is always set here. - setIsPaused(false); + // was started, so the active option is always set here. resumeDownload + // clears pauseRequested. resumeDownload(activeOption!.starter.tier, activeOption!, pausedBytes); }, [activeOption, pausedBytes, resumeDownload]); const discardActive = useCallback(() => { - setIsPaused(false); + setPauseRequested(false); void discard(activeOption!.starter.sha256); setActiveOption(null); }, [activeOption, discard]); diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx index a192198d..83e31cbc 100644 --- a/src/contexts/__tests__/DownloadContext.test.tsx +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -148,9 +148,14 @@ describe('DownloadContext', () => { result.current.pauseDownload(); }); - expect(result.current.isPaused).toBe(true); + // Cancel fired and the bytes were captured, but the pause is NOT committed + // until the backend Cancelled lands (slot released) so a resume cannot race. expect(result.current.pausedBytes).toBe(60); expect(invoke).toHaveBeenCalledWith('cancel_model_download'); + expect(result.current.isPaused).toBe(false); + + act(() => channel().simulateMessage({ type: 'Cancelled' })); + expect(result.current.isPaused).toBe(true); }); it('pauseDownload defaults to zero bytes before the first event arrives', async () => { @@ -162,6 +167,7 @@ describe('DownloadContext', () => { await act(async () => { result.current.pauseDownload(); }); + act(() => channel().simulateMessage({ type: 'Cancelled' })); expect(result.current.isPaused).toBe(true); expect(result.current.pausedBytes).toBe(0); @@ -177,6 +183,9 @@ describe('DownloadContext', () => { await act(async () => { result.current.pauseDownload(); }); + act(() => channel().simulateMessage({ type: 'Cancelled' })); + expect(result.current.isPaused).toBe(true); + await act(async () => { result.current.resumeFromPause(); }); @@ -198,6 +207,8 @@ describe('DownloadContext', () => { await act(async () => { result.current.pauseDownload(); }); + act(() => channel().simulateMessage({ type: 'Cancelled' })); + await act(async () => { result.current.discardActive(); }); From 19c8693ef1304dabbe4d8ea7865d51aa2736e402 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 20:52:56 -0500 Subject: [PATCH 28/51] fix: show an instant Pausing state so the Pause click is never silent Signed-off-by: Logan Nguyen --- src/App.tsx | 41 +++++++++++-------- src/__tests__/App.test.tsx | 24 +++++++++++ src/components/DownloadStatusStrip.tsx | 9 ++++ .../__tests__/DownloadStatusStrip.test.tsx | 6 +++ src/contexts/DownloadContext.tsx | 12 ++++++ .../__tests__/DownloadContext.test.tsx | 7 +++- src/view/AskBarView.tsx | 4 +- src/view/__tests__/AskBarView.test.tsx | 17 ++++++++ 8 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d63c4924..58b94f59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -433,6 +433,7 @@ function App() { speedBytesPerSec: downloadSpeedBytesPerSec, retry: retryDownload, isPaused: isDownloadPaused, + isPausing: isDownloadPausing, pausedBytes: downloadPausedBytes, pauseDownload, resumeFromPause, @@ -2439,21 +2440,27 @@ function App() { * settled phases (idle, confirm, resume), so no strip renders. */ const downloadStripStatus = useMemo(() => { - // Paused overrides the machine phase (which is idle after a cancel): the - // strip stays, now offering Resume / Discard. + const total = downloadGrandTotalBytes; + const liveBytes = downloadCombinedBytes ?? downloadResumeSeedBytes; + const percentOf = (bytes: number | null): number => + bytes !== null && total !== null && total > 0 + ? Math.min(100, Math.floor((bytes / total) * 100)) + : 0; + // Paused overrides the machine phase (idle after a cancel): the strip + // stays, now offering Resume / Discard. if (isDownloadPaused) { - const total = downloadGrandTotalBytes; - const percent = - total !== null && total > 0 - ? Math.min(100, Math.floor((downloadPausedBytes / total) * 100)) - : 0; return { kind: 'paused', - percent, + percent: percentOf(downloadPausedBytes), onResume: resumeFromPause, onDiscard: discardActive, }; } + // Transitional: Pause clicked but the cancel has not landed yet. Shown + // instantly so the click is never silent. + if (isDownloadPausing) { + return { kind: 'pausing', percent: percentOf(liveBytes) }; + } if (downloadPhase === 'ready') return { kind: 'ready' }; if (downloadPhase === 'failed') { return { @@ -2463,19 +2470,18 @@ function App() { }; } if (isDownloadInFlight(downloadPhase)) { - const bytes = downloadCombinedBytes ?? downloadResumeSeedBytes; - const total = downloadGrandTotalBytes; - const percent = - bytes !== null && total !== null && total > 0 - ? Math.min(100, Math.floor((bytes / total) * 100)) - : 0; const etaSeconds = - bytes !== null && total !== null && downloadSpeedBytesPerSec !== null - ? Math.max(0, Math.round((total - bytes) / downloadSpeedBytesPerSec)) + liveBytes !== null && + total !== null && + downloadSpeedBytesPerSec !== null + ? Math.max( + 0, + Math.round((total - liveBytes) / downloadSpeedBytesPerSec), + ) : null; return { kind: 'downloading', - percent, + percent: percentOf(liveBytes), etaSeconds, onPause: pauseDownload, }; @@ -2483,6 +2489,7 @@ function App() { return null; }, [ isDownloadPaused, + isDownloadPausing, downloadPausedBytes, downloadPhase, downloadCombinedBytes, diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 52eeef7f..8ab79cb8 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -75,6 +75,7 @@ function makeDownloadCtx( beginDownload: vi.fn(), resumeDownload: vi.fn(), isPaused: false, + isPausing: false, pausedBytes: 0, pauseDownload: vi.fn(), resumeFromPause: vi.fn(), @@ -7911,6 +7912,29 @@ describe('App', () => { expect(pauseDownload).toHaveBeenCalledTimes(1); }); + it('shows a Pausing… strip the instant Pause is requested', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: null, + all: [], + ollamaReachable: true, + }, + }); + downloadHolder.value = makeDownloadCtx({ + state: { phase: 'downloading' }, + isPausing: true, + combinedBytes: 4_000_000_000, + grandTotalBytes: 10_000_000_000, + speedBytesPerSec: 8_000_000, + }); + + render(builtinTree()); + await act(async () => {}); + await showOverlay(); + + expect(screen.getByText('Pausing…')).toBeInTheDocument(); + }); + it('shows a paused strip with Resume / Discard and the held percent', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { diff --git a/src/components/DownloadStatusStrip.tsx b/src/components/DownloadStatusStrip.tsx index 06e455d4..fca4b809 100644 --- a/src/components/DownloadStatusStrip.tsx +++ b/src/components/DownloadStatusStrip.tsx @@ -24,6 +24,7 @@ export type DownloadStripStatus = onResume: () => void; onDiscard: () => void; } + | { kind: 'pausing'; percent: number } | { kind: 'ready' } | { kind: 'failed'; message: string; onRetry: () => void }; @@ -149,6 +150,14 @@ export function DownloadStatusStrip({ ); } + if (status.kind === 'pausing') { + return ( + + Pausing… + + ); + } + if (status.kind === 'paused') { return ( diff --git a/src/components/__tests__/DownloadStatusStrip.test.tsx b/src/components/__tests__/DownloadStatusStrip.test.tsx index b9d7372a..a65a163d 100644 --- a/src/components/__tests__/DownloadStatusStrip.test.tsx +++ b/src/components/__tests__/DownloadStatusStrip.test.tsx @@ -68,6 +68,12 @@ describe('DownloadStatusStrip', () => { expect(onPause).toHaveBeenCalledTimes(1); }); + it('shows a pausing state (no controls) while the cancel lands', () => { + render(); + expect(screen.getByText('Pausing…')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + it('shows a paused state with Resume and Discard', () => { const onResume = vi.fn(); const onDiscard = vi.fn(); diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index ac04b537..9799a8b1 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -22,6 +22,7 @@ import { type ReactNode, } from 'react'; import { + isDownloadInFlight, useDownloadModel, type UseDownloadModel, } from '../hooks/useDownloadModel'; @@ -59,6 +60,12 @@ export interface DownloadContextValue extends UseDownloadModel { ) => void; /** True while a started download has been paused (cancelled, partial kept). */ isPaused: boolean; + /** + * True the instant Pause is clicked, until the cancel lands (the download is + * still in flight). Drives the transitional "Pausing…" strip so the click + * has immediate feedback before `isPaused` commits at idle. + */ + isPausing: boolean; /** Bytes downloaded at the moment of pause, for the paused strip's percent. */ pausedBytes: number; /** Pause the in-flight download: cancel it; the partial stays on disk. */ @@ -90,6 +97,9 @@ export function DownloadProvider({ children }: { children: ReactNode }) { // free, so a resume can never collide with the download it replaces and fail // with "a download is already in progress". const isPaused = pauseRequested && downloadPhase === 'idle'; + // Transitional: the cancel is requested but the download is still winding + // down. The strip shows "Pausing…" here so the Pause click is never silent. + const isPausing = pauseRequested && isDownloadInFlight(downloadPhase); const beginDownload = useCallback( (tier: StarterTier, option: StarterOption) => { @@ -150,6 +160,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { beginDownload, resumeDownload, isPaused, + isPausing, pausedBytes, pauseDownload, resumeFromPause, @@ -164,6 +175,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { beginDownload, resumeDownload, isPaused, + isPausing, pausedBytes, pauseDownload, resumeFromPause, diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx index 83e31cbc..a6806c41 100644 --- a/src/contexts/__tests__/DownloadContext.test.tsx +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -148,14 +148,17 @@ describe('DownloadContext', () => { result.current.pauseDownload(); }); - // Cancel fired and the bytes were captured, but the pause is NOT committed - // until the backend Cancelled lands (slot released) so a resume cannot race. + // Cancel fired and the bytes were captured. The pause is NOT committed + // until the backend Cancelled lands (slot released) so a resume cannot + // race; meanwhile `isPausing` is true for instant "Pausing…" feedback. expect(result.current.pausedBytes).toBe(60); expect(invoke).toHaveBeenCalledWith('cancel_model_download'); expect(result.current.isPaused).toBe(false); + expect(result.current.isPausing).toBe(true); act(() => channel().simulateMessage({ type: 'Cancelled' })); expect(result.current.isPaused).toBe(true); + expect(result.current.isPausing).toBe(false); }); it('pauseDownload defaults to zero bytes before the first event arrives', async () => { diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx index be367f71..1d2bf2e4 100644 --- a/src/view/AskBarView.tsx +++ b/src/view/AskBarView.tsx @@ -268,7 +268,9 @@ export function AskBarView({ // submit (App soft-blocks it), so the send affordance is greyed to match: // the input stays editable for drafting, but there is nothing to send yet. const isDownloadHolding = - downloadStatus?.kind === 'downloading' || downloadStatus?.kind === 'paused'; + downloadStatus?.kind === 'downloading' || + downloadStatus?.kind === 'pausing' || + downloadStatus?.kind === 'paused'; const canSubmit = (query.trim().length > 0 || attachedImages.length > 0) && !isBusy && diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx index 9369d882..6a1e94d2 100644 --- a/src/view/__tests__/AskBarView.test.tsx +++ b/src/view/__tests__/AskBarView.test.tsx @@ -166,6 +166,23 @@ describe('AskBarView', () => { expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); }); + it('keeps the send button disabled while a download is pausing', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + }); + it('keeps the send button disabled while a download is paused', () => { render( Date: Wed, 17 Jun 2026 21:42:13 -0500 Subject: [PATCH 29/51] feat: label the resume re-hash Verifying and make it cancellable Signed-off-by: Logan Nguyen --- src-tauri/src/models/download.rs | 70 ++++++++++++++++++- src-tauri/src/models/storage.rs | 68 ++++++++++++++++-- src/App.tsx | 6 ++ src/__tests__/App.test.tsx | 25 +++++++ src/components/DownloadStatusStrip.tsx | 12 ++++ .../__tests__/DownloadStatusStrip.test.tsx | 6 ++ src/hooks/__tests__/useDownloadModel.test.tsx | 58 +++++++++++++++ src/hooks/useDownloadModel.ts | 11 +++ src/view/AskBarView.tsx | 1 + src/view/__tests__/AskBarView.test.tsx | 17 +++++ 10 files changed, 268 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/models/download.rs b/src-tauri/src/models/download.rs index 2811429f..4013b926 100644 --- a/src-tauri/src/models/download.rs +++ b/src-tauri/src/models/download.rs @@ -255,8 +255,19 @@ async fn fetch_into_partial( // must cover the full body and nothing that came before it. let mut hasher = Sha256::new(); if start > 0 { + // The resume re-hash reads the entire on-disk prefix back through + // SHA-256 to seed the running hash: seconds of blocking I/O on a + // multi-GB partial. Label it so the bar is not a silent frozen mystery. + emit(DownloadEvent::Verifying { + file: spec.file.clone(), + }); + // Cancellable so a pause during the re-hash lands instantly instead of + // after the whole prefix is read. A cancelled re-hash stops with a + // partial (discarded) hash; the cancel token is still set, so the + // stream loop below returns Cancelled at its first check before writing + // anything, keeping the on-disk partial intact for a later resume. store - .feed_partial(&spec.sha256, &mut hasher) + .feed_partial(&spec.sha256, &mut hasher, &|| cancel.is_cancelled()) .map_err(DownloadIoError::Write)?; } @@ -564,6 +575,63 @@ mod tests { assert_eq!(std::fs::read(store.blob_path(&sha)).unwrap(), body); } + #[tokio::test] + async fn resume_emits_verifying_before_rehash() { + // On resume the existing prefix is re-hashed before the remaining bytes + // stream. That re-hash is labeled with a Verifying event so the bar is + // not a silent frozen mystery, so a Verifying must precede every + // streamed Progress (the end-of-download Verifying comes much later). + let server = MockServer::start().await; + let body = body_of(8192); + let sha = sha256_of(&body); + Mock::given(method("GET")) + .and(path("/q/resolve/main/w.gguf")) + .and(header("range", "bytes=1000-")) + .respond_with(ResponseTemplate::new(206).set_body_bytes(body[1000..].to_vec())) + .mount(&server) + .await; + + let (_dir, store) = make_store(); + std::fs::write(store.partial_path(&sha), &body[..1000]).unwrap(); + let spec = spec_for( + format!("{}/q/resolve/main/w.gguf", server.uri()), + "w.gguf", + &body, + ); + let (events, emit) = collector(); + + let result = run_download( + &[spec], + &store, + &reqwest::Client::new(), + CancellationToken::new(), + emit, + ) + .await; + assert_eq!(result, Ok(())); + + let events = events.lock().unwrap(); + assert!(matches!( + events[0], + DownloadEvent::Started { + resumed_from: 1000, + .. + } + )); + let first_verifying = events + .iter() + .position(|e| matches!(e, DownloadEvent::Verifying { .. })) + .unwrap(); + let first_progress = events + .iter() + .position(|e| matches!(e, DownloadEvent::Progress { .. })) + .unwrap(); + assert!( + first_verifying < first_progress, + "the re-hash Verifying must precede any streamed Progress" + ); + } + #[tokio::test] async fn range_ignored_by_server_restarts_from_scratch() { let server = MockServer::start().await; diff --git a/src-tauri/src/models/storage.rs b/src-tauri/src/models/storage.rs index 01064eb8..fa307b6a 100644 --- a/src-tauri/src/models/storage.rs +++ b/src-tauri/src/models/storage.rs @@ -71,10 +71,28 @@ impl ModelStore { /// buffer (never whole-file in memory). Used to hash a full-length partial /// that was never streamed live, and to seed an incremental hasher with the /// bytes already on disk before a resumed download appends the rest. - pub fn feed_partial(&self, sha256: &str, sink: &mut W) -> io::Result<()> { - let file = std::fs::File::open(self.partial_path(sha256))?; - let mut reader = io::BufReader::with_capacity(BLOB_HASH_BUFFER_BYTES, file); - io::copy(&mut reader, sink)?; + /// + /// `cancelled` is polled once per read buffer (every + /// [`BLOB_HASH_BUFFER_BYTES`]); when it returns true the read stops early, + /// so a pause during a multi-GB resume re-hash lands promptly instead of + /// after the whole prefix is read. A cancelled read leaves a partial sink; + /// callers that cancel discard the sink (the running hash) entirely. + pub fn feed_partial( + &self, + sha256: &str, + sink: &mut W, + cancelled: &dyn Fn() -> bool, + ) -> io::Result<()> { + use io::Read; + let mut file = std::fs::File::open(self.partial_path(sha256))?; + let mut buf = vec![0u8; BLOB_HASH_BUFFER_BYTES]; + while !cancelled() { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + sink.write_all(&buf[..n])?; + } Ok(()) } @@ -106,7 +124,9 @@ impl ModelStore { /// [`StorageError::VerifyFailed`] is returned. pub fn verify_and_install(&self, sha256: &str) -> Result { let mut hasher = Sha256::new(); - self.feed_partial(sha256, &mut hasher)?; + // A full-length-partial verify always runs to completion: there is no + // pause surface for it, so it never cancels. + self.feed_partial(sha256, &mut hasher, &|| false)?; let actual = format!("{:x}", hasher.finalize()); self.install_if_matches(sha256, &actual) } @@ -263,6 +283,44 @@ mod tests { assert!(matches!(err, StorageError::Io(_))); } + // ── feed_partial cancellation ──────────────────────────────────────────── + + #[test] + fn feed_partial_reads_the_whole_partial_when_not_cancelled() { + let (_dir, store) = make_store(); + let sha = "feeddone"; + let data = b"some bytes to stream through the sink"; + write_partial(&store, sha, data); + + let mut sink = Vec::new(); + store.feed_partial(sha, &mut sink, &|| false).unwrap(); + assert_eq!(sink, data); + } + + #[test] + fn feed_partial_stops_early_when_cancelled() { + let (_dir, store) = make_store(); + let sha = "feedcancel"; + // Two full read buffers, so the cancel can land after the first. + let data = vec![7u8; BLOB_HASH_BUFFER_BYTES * 2]; + write_partial(&store, sha, &data); + + let mut sink = Vec::new(); + let checks = std::cell::Cell::new(0u32); + store + .feed_partial(sha, &mut sink, &|| { + let n = checks.get(); + checks.set(n + 1); + // False on the first check (one buffer is read), true after. + n >= 1 + }) + .unwrap(); + assert!( + sink.len() < data.len(), + "feed_partial must stop before reading the whole partial" + ); + } + // ── remove_blobs ───────────────────────────────────────────────────────── #[test] diff --git a/src/App.tsx b/src/App.tsx index 58b94f59..eeb8091c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2469,6 +2469,12 @@ function App() { onRetry: () => void retryDownload(), }; } + // The integrity re-hash on resume (and the brief end-of-download verify) + // gets its own label, distinct from the byte-moving downloading step. It is + // in-flight, so this must precede the generic downloading branch below. + if (downloadPhase === 'verifying') { + return { kind: 'verifying', percent: percentOf(liveBytes) }; + } if (isDownloadInFlight(downloadPhase)) { const etaSeconds = liveBytes !== null && diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 8ab79cb8..a4465626 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -7790,6 +7790,31 @@ describe('App', () => { expect(screen.getByText('0%')).toBeInTheDocument(); }); + it('shows a Verifying… strip while the download is verifying', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: null, + all: [], + ollamaReachable: true, + }, + }); + downloadHolder.value = makeDownloadCtx({ + state: { phase: 'verifying' }, + combinedBytes: 4_000_000_000, + grandTotalBytes: 10_000_000_000, + }); + + render(builtinTree()); + await act(async () => {}); + await showOverlay(); + + expect(screen.getByText('Verifying…')).toBeInTheDocument(); + // The strip owns the messaging: the downloading label is not shown. + expect( + screen.queryByText('Setting up your model'), + ).not.toBeInTheDocument(); + }); + it('soft-blocks submit while downloading, without sending or shaking', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { diff --git a/src/components/DownloadStatusStrip.tsx b/src/components/DownloadStatusStrip.tsx index fca4b809..d01da541 100644 --- a/src/components/DownloadStatusStrip.tsx +++ b/src/components/DownloadStatusStrip.tsx @@ -25,6 +25,7 @@ export type DownloadStripStatus = onDiscard: () => void; } | { kind: 'pausing'; percent: number } + | { kind: 'verifying'; percent: number } | { kind: 'ready' } | { kind: 'failed'; message: string; onRetry: () => void }; @@ -158,6 +159,17 @@ export function DownloadStatusStrip({ ); } + if (status.kind === 'verifying') { + // The integrity re-hash on resume (and the brief end-of-download verify): + // an active working step, so it keeps the orange treatment but offers no + // controls of its own. + return ( + + Verifying… + + ); + } + if (status.kind === 'paused') { return ( diff --git a/src/components/__tests__/DownloadStatusStrip.test.tsx b/src/components/__tests__/DownloadStatusStrip.test.tsx index a65a163d..8c4c26f5 100644 --- a/src/components/__tests__/DownloadStatusStrip.test.tsx +++ b/src/components/__tests__/DownloadStatusStrip.test.tsx @@ -89,6 +89,12 @@ describe('DownloadStatusStrip', () => { expect(onDiscard).toHaveBeenCalledTimes(1); }); + it('shows a verifying state (no controls) during the re-hash', () => { + render(); + expect(screen.getByText('Verifying…')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + it('shows a ready state', () => { render(); expect(screen.getByText('Model ready')).toBeInTheDocument(); diff --git a/src/hooks/__tests__/useDownloadModel.test.tsx b/src/hooks/__tests__/useDownloadModel.test.tsx index 6831bd54..42b76f1e 100644 --- a/src/hooks/__tests__/useDownloadModel.test.tsx +++ b/src/hooks/__tests__/useDownloadModel.test.tsx @@ -145,6 +145,64 @@ describe('useDownloadModel', () => { expect(result.current.state).toEqual({ phase: 'ready' }); }); + it('flips a post-re-hash Progress back to the active downloading phase', async () => { + // On resume the prefix is re-hashed (Verifying) before the remaining bytes + // stream. The first streamed Progress must flip the label back to + // downloading so the resumed transfer is not mislabeled "Verifying". + const { result } = renderHook(() => useDownloadModel()); + await act(() => result.current.start('balanced')); + + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'w.gguf', total_bytes: 100, resumed_from: 40 }, + }), + ); + act(() => + channel().simulateMessage({ + type: 'Verifying', + data: { file: 'w.gguf' }, + }), + ); + expect(result.current.state).toEqual({ phase: 'verifying' }); + act(() => + channel().simulateMessage({ + type: 'Progress', + data: { file: 'w.gguf', bytes: 50, total_bytes: 100 }, + }), + ); + expect(result.current.state).toEqual({ phase: 'downloading' }); + + // The vision companion resumes too: its re-hash Verifying flips back to the + // mmproj downloading phase, not the plain one. + act(() => + channel().simulateMessage({ + type: 'FileDone', + data: { file: 'w.gguf' }, + }), + ); + act(() => + channel().simulateMessage({ + type: 'Started', + data: { file: 'mmproj.gguf', total_bytes: 50, resumed_from: 20 }, + }), + ); + act(() => + channel().simulateMessage({ + type: 'Verifying', + data: { file: 'mmproj.gguf' }, + }), + ); + expect(result.current.state).toEqual({ phase: 'verifying' }); + act(() => + channel().simulateMessage({ + type: 'Progress', + data: { file: 'mmproj.gguf', bytes: 30, total_bytes: 50 }, + }), + ); + expect(result.current.state).toEqual({ phase: 'downloading_mmproj' }); + }); + it('drops ETA samples older than the 10s window', async () => { const now = vi.spyOn(Date, 'now').mockReturnValue(0); const { result } = renderHook(() => useDownloadModel()); diff --git a/src/hooks/useDownloadModel.ts b/src/hooks/useDownloadModel.ts index be461823..cf85daa3 100644 --- a/src/hooks/useDownloadModel.ts +++ b/src/hooks/useDownloadModel.ts @@ -233,6 +233,17 @@ export function useDownloadModel( ); setSpeedBytesPerSec(computeSpeedBytesPerSec(samples)); setCombinedBytes(completedBytesRef.current + event.data.bytes); + // A resume re-hash labels itself `verifying` before the remaining + // bytes stream; the first streamed Progress returns the label to the + // active downloading phase so the transfer is not mislabeled. Any + // other phase is left untouched (same reference → no re-render). + setState((prev) => + prev.phase === 'verifying' + ? startedCountRef.current >= 2 + ? { phase: 'downloading_mmproj' } + : { phase: 'downloading' } + : prev, + ); break; } case 'Verifying': diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx index 1d2bf2e4..e50b0a71 100644 --- a/src/view/AskBarView.tsx +++ b/src/view/AskBarView.tsx @@ -270,6 +270,7 @@ export function AskBarView({ const isDownloadHolding = downloadStatus?.kind === 'downloading' || downloadStatus?.kind === 'pausing' || + downloadStatus?.kind === 'verifying' || downloadStatus?.kind === 'paused'; const canSubmit = (query.trim().length > 0 || attachedImages.length > 0) && diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx index 6a1e94d2..2d49b2f8 100644 --- a/src/view/__tests__/AskBarView.test.tsx +++ b/src/view/__tests__/AskBarView.test.tsx @@ -205,6 +205,23 @@ describe('AskBarView', () => { expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); }); + it('keeps the send button disabled while a download is verifying', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + }); + it('keeps the send button enabled once the download is ready', () => { render( Date: Wed, 17 Jun 2026 22:03:52 -0500 Subject: [PATCH 30/51] feat: auto-resume a relaunched download instead of bouncing to the picker Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 50 +++--- src-tauri/src/onboarding.rs | 100 ------------ src/contexts/DownloadContext.tsx | 29 ++++ .../__tests__/DownloadContext.test.tsx | 144 ++++++++++++++++++ .../__tests__/ModelCheckStep.test.tsx | 3 + 5 files changed, 198 insertions(+), 128 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f44beaad..ec896af0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1074,36 +1074,15 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State>() - .read() - .inference - .active_provider_kind() - == "builtin"; - let has_model = crate::models::manifest::list(&conn) - .map(|m| !m.is_empty()) - .unwrap_or(false); - let model_store = app_handle.state::(); - let has_partial = crate::models::build_starter_options( - &conn, - &model_store, - crate::models::system_ram_bytes(), - ) - .iter() - .any(|o| o.partial_bytes.is_some()); - let stage = - onboarding::apply_model_gate(raw_stage, is_builtin, has_model, has_partial); - // The "intro" stage means the user already cleared both the // permissions and the model-check gates on a previous launch. // Skip the live permission re-check here: on macOS 15+ @@ -1157,6 +1136,20 @@ fn notify_frontend_ready(app_handle: tauri::AppHandle, db: tauri::State, +) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + onboarding::get_stage(&conn).map_err(|e| e.to_string()) +} + /// Advances the onboarding stage from `model_check` to `intro` and emits /// the onboarding event so the frontend swaps to `IntroStep` without a /// window flicker. @@ -2185,6 +2178,7 @@ pub fn run() { permissions::quit_and_relaunch, finish_onboarding, advance_past_model_check, + onboarding_stage, #[cfg(not(coverage))] warmup::warm_up_model, #[cfg(not(coverage))] diff --git a/src-tauri/src/onboarding.rs b/src-tauri/src/onboarding.rs index 77eacb3f..497a47ad 100644 --- a/src-tauri/src/onboarding.rs +++ b/src-tauri/src/onboarding.rs @@ -89,37 +89,6 @@ pub fn mark_complete(conn: &Connection) -> rusqlite::Result<()> { set_stage(conn, &OnboardingStage::Complete) } -/// Relaunch-safety gate over the persisted stage. -/// -/// The non-blocking download experience lets the user leave the picker while a -/// built-in model is still downloading: tapping "Continue setup" persists -/// `intro`, and finishing the intro tour persists `complete`, all before the -/// download completes. A quit + relaunch mid-download would otherwise strand -/// the user past model selection with no usable model. -/// -/// So when the persisted stage is past model selection (`intro`/`complete`) -/// but the built-in engine is active with zero installed models AND a resumable -/// partial is on disk, force the user back to `model_check` to finish (resume) -/// the download. Every other case is returned unchanged. -/// -/// The partial is the load-bearing signal: a deliberate delete-model-in-Settings -/// leaves no partial, so it does NOT re-trigger onboarding. Stages before model -/// selection (`permissions`/`model_check`) are never touched: the user has not -/// reached the picker yet, so there is nothing to relaunch-protect. -pub fn apply_model_gate( - stage: OnboardingStage, - is_builtin: bool, - has_model: bool, - has_partial: bool, -) -> OnboardingStage { - let past_model_selection = matches!(stage, OnboardingStage::Intro | OnboardingStage::Complete); - if past_model_selection && is_builtin && !has_model && has_partial { - OnboardingStage::ModelCheck - } else { - stage - } -} - // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -261,73 +230,4 @@ mod tests { let result = compute_startup_stage(&conn).unwrap(); assert_eq!(result, Some(OnboardingStage::Intro)); } - - // ── apply_model_gate (relaunch safety) ─────────────────────────────────── - - #[test] - fn model_gate_forces_model_check_from_intro_mid_download() { - // Quit while the model was still downloading after tapping Continue: - // builtin, no model installed yet, a partial left on disk. - assert_eq!( - apply_model_gate(OnboardingStage::Intro, true, false, true), - OnboardingStage::ModelCheck - ); - } - - #[test] - fn model_gate_forces_model_check_from_complete_mid_download() { - // Quit after Get Started but before the download finished. - assert_eq!( - apply_model_gate(OnboardingStage::Complete, true, false, true), - OnboardingStage::ModelCheck - ); - } - - #[test] - fn model_gate_keeps_stage_when_a_model_is_installed() { - // Download completed: a model exists, so nothing to recover. - assert_eq!( - apply_model_gate(OnboardingStage::Intro, true, true, true), - OnboardingStage::Intro - ); - assert_eq!( - apply_model_gate(OnboardingStage::Complete, true, true, false), - OnboardingStage::Complete - ); - } - - #[test] - fn model_gate_ignores_a_deliberate_delete_with_no_partial() { - // Model deleted in Settings (no partial left): must NOT re-onboard. - assert_eq!( - apply_model_gate(OnboardingStage::Intro, true, false, false), - OnboardingStage::Intro - ); - assert_eq!( - apply_model_gate(OnboardingStage::Complete, true, false, false), - OnboardingStage::Complete - ); - } - - #[test] - fn model_gate_ignores_non_builtin_providers() { - // An Ollama/openai user with a stray partial is never re-gated. - assert_eq!( - apply_model_gate(OnboardingStage::Complete, false, false, true), - OnboardingStage::Complete - ); - } - - #[test] - fn model_gate_leaves_pre_selection_stages_untouched() { - // Before the picker there is nothing to relaunch-protect. - assert_eq!( - apply_model_gate(OnboardingStage::Permissions, true, false, true), - OnboardingStage::Permissions - ); - assert_eq!( - apply_model_gate(OnboardingStage::ModelCheck, true, false, true), - OnboardingStage::ModelCheck - ); - } } diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index 9799a8b1..a865158f 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -17,15 +17,19 @@ import { createContext, use, useCallback, + useEffect, useMemo, + useRef, useState, type ReactNode, } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { isDownloadInFlight, useDownloadModel, type UseDownloadModel, } from '../hooks/useDownloadModel'; +import { useConfig } from './ConfigContext'; import type { StarterOption, StarterTier } from '../types/starter'; export interface DownloadContextValue extends UseDownloadModel { @@ -123,6 +127,31 @@ export function DownloadProvider({ children }: { children: ReactNode }) { [resume], ); + // On launch, recover an interrupted built-in download: if the engine is the + // active provider and a starter has a partial on disk but none is installed, + // resume it in the background so the ambient strip is the recovery surface. + // The relaunch no longer bounces the user back to the picker, so this is what + // keeps them from being stranded with no model. Fires once: the ref guards + // against the StrictMode double-invoke and any later provider re-render. + const activeProviderKind = useConfig().inference.activeProviderKind; + const autoResumedRef = useRef(false); + useEffect(() => { + if (autoResumedRef.current) return; + autoResumedRef.current = true; + if (activeProviderKind !== 'builtin') return; + void (async () => { + // The model_check picker owns the resume decision (its own Resume / + // Discard choice), so only auto-resume once the user is past it: the + // intro tour or the ask bar. + const stage = await invoke('onboarding_stage'); + if (stage !== 'intro' && stage !== 'complete') return; + const options = await invoke('get_starter_options'); + const partial = options.find((o) => o.partial_bytes !== null); + if (options.some((o) => o.installed) || partial === undefined) return; + resumeDownload(partial.starter.tier, partial, partial.partial_bytes!); + })(); + }, [activeProviderKind, resumeDownload]); + const pauseDownload = useCallback(() => { // Remember how far we got so the paused strip can show the percent, then // cancel the run (the backend keeps the partial on disk for resume). The diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx index a6806c41..8583fcfb 100644 --- a/src/contexts/__tests__/DownloadContext.test.tsx +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { ReactNode } from 'react'; import { DownloadProvider, useDownloadCtx } from '../DownloadContext'; +import { ConfigProviderForTest, DEFAULT_CONFIG } from '../ConfigContext'; import { invoke, enableChannelCapture, @@ -51,6 +52,39 @@ function wrapper({ children }: { children: ReactNode }) { return {children}; } +/** AppConfig whose active provider is the bundled built-in engine. */ +const BUILTIN_CONFIG = { + ...DEFAULT_CONFIG, + inference: { + ...DEFAULT_CONFIG.inference, + activeProvider: 'builtin', + activeProviderKind: 'builtin', + }, +}; + +/** Provider tree with the built-in engine active. */ +function builtinWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Counts how many times `invoke` was called for a given command. */ +function invokeCount(command: string): number { + return invoke.mock.calls.filter((c) => c[0] === command).length; +} + +/** Stub the launch probes: the persisted onboarding stage and the starters. */ +function mockLaunch(stage: string, options: StarterOption[] = []) { + invoke.mockImplementation((cmd) => { + if (cmd === 'onboarding_stage') return Promise.resolve(stage); + if (cmd === 'get_starter_options') return Promise.resolve(options); + return Promise.resolve(); + }); +} + describe('DownloadContext', () => { beforeEach(() => { invoke.mockReset(); @@ -223,4 +257,114 @@ describe('DownloadContext', () => { sha256: 'deadbeef', }); }); + + describe('launch auto-resume', () => { + it('resumes an interrupted partial past the picker (intro) with no installed model', async () => { + const partial: StarterOption = { + ...option({ tier: 'fast', size_bytes: 4_000_000_000, mmproj_bytes: 0 }), + partial_bytes: 3_000_000_000, + }; + mockLaunch('intro', [partial]); + + const { result } = renderHook(() => useDownloadCtx(), { + wrapper: builtinWrapper, + }); + await act(async () => {}); + + expect(invokeCount('get_starter_options')).toBe(1); + expect(result.current.downloadingTier).toBe('fast'); + expect(result.current.resumeSeedBytes).toBe(3_000_000_000); + expect(result.current.state).toEqual({ phase: 'downloading' }); + expect(invoke).toHaveBeenCalledWith('download_starter', { + tier: 'fast', + onEvent: expect.anything(), + }); + }); + + it('does not resume at the model_check picker (it owns the resume choice)', async () => { + const partial: StarterOption = { + ...option(), + partial_bytes: 3_000_000_000, + }; + mockLaunch('model_check', [partial]); + + const { result } = renderHook(() => useDownloadCtx(), { + wrapper: builtinWrapper, + }); + await act(async () => {}); + + // Gated out before probing the starters; the picker handles the partial. + expect(invokeCount('get_starter_options')).toBe(0); + expect(result.current.state).toEqual({ phase: 'idle' }); + }); + + it('does not resume when a model is already installed (complete stage)', async () => { + const installed: StarterOption = { ...option(), installed: true }; + mockLaunch('complete', [installed]); + + const { result } = renderHook(() => useDownloadCtx(), { + wrapper: builtinWrapper, + }); + await act(async () => {}); + + expect(invokeCount('get_starter_options')).toBe(1); + expect(result.current.state).toEqual({ phase: 'idle' }); + expect(invokeCount('download_starter')).toBe(0); + }); + + it('does not resume when no partial is on disk', async () => { + mockLaunch('intro', [option()]); + + const { result } = renderHook(() => useDownloadCtx(), { + wrapper: builtinWrapper, + }); + await act(async () => {}); + + expect(invokeCount('get_starter_options')).toBe(1); + expect(result.current.state).toEqual({ phase: 'idle' }); + expect(invokeCount('download_starter')).toBe(0); + }); + + it('does not probe anything when the active provider is not the built-in engine', async () => { + const { result } = renderHook(() => useDownloadCtx(), { wrapper }); + await act(async () => {}); + + expect(invokeCount('onboarding_stage')).toBe(0); + expect(invokeCount('get_starter_options')).toBe(0); + expect(result.current.state).toEqual({ phase: 'idle' }); + }); + + it('fires once: a later provider change does not re-trigger the launch probe', async () => { + mockLaunch('intro', [{ ...option(), partial_bytes: 1_000 }]); + + let cfg = BUILTIN_CONFIG; + function mutableWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + const { rerender } = renderHook(() => useDownloadCtx(), { + wrapper: mutableWrapper, + }); + await act(async () => {}); + expect(invokeCount('onboarding_stage')).toBe(1); + + // Flipping the active provider re-runs the effect; the fire-once ref + // blocks a second probe. + cfg = { + ...BUILTIN_CONFIG, + inference: { + ...BUILTIN_CONFIG.inference, + activeProviderKind: 'ollama', + }, + }; + await act(async () => { + rerender(); + }); + expect(invokeCount('onboarding_stage')).toBe(1); + }); + }); }); diff --git a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx index 729c6dfc..57a39480 100644 --- a/src/view/onboarding/__tests__/ModelCheckStep.test.tsx +++ b/src/view/onboarding/__tests__/ModelCheckStep.test.tsx @@ -759,6 +759,9 @@ const BUILTIN_CONFIG: AppConfig = { function builtinResponses(overrides: Record = {}) { enableChannelCaptureWithResponses({ + // This flow IS the model_check picker, which owns the resume decision, so + // the DownloadProvider's launch auto-resume gates itself out here. + onboarding_stage: 'model_check', check_model_setup: { state: 'needs_download' }, get_starter_options: BUILTIN_OPTIONS, detect_ollama: true, From 43d653ab46c447528c028122d18be213e65287a6 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 22:59:35 -0500 Subject: [PATCH 31/51] fix: give the intro tour window room for the download strip Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ec896af0..fe0bdb7c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -199,6 +199,12 @@ const ONBOARDING_EVENT: &str = "thuki://onboarding"; /// without extra transparent padding. const ONBOARDING_LOGICAL_WIDTH: f64 = 460.0; const ONBOARDING_LOGICAL_HEIGHT: f64 = 640.0; +/// The intro tour is taller than the other simple stages: it can carry the +/// ambient download strip pinned at the base of its card (a relaunch +/// mid-download lands here and auto-resumes), and the window is sized once at +/// show time, so it must reserve the strip's height up front or the strip +/// clips against the window's bottom edge. +const ONBOARDING_INTRO_HEIGHT: f64 = 720.0; const ONBOARDING_PICKER_WIDTH: f64 = 860.0; const ONBOARDING_PICKER_HEIGHT: f64 = 744.0; @@ -211,6 +217,7 @@ fn onboarding_window_size(stage: &onboarding::OnboardingStage) -> (f64, f64) { onboarding::OnboardingStage::ModelCheck => { (ONBOARDING_PICKER_WIDTH, ONBOARDING_PICKER_HEIGHT) } + onboarding::OnboardingStage::Intro => (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_INTRO_HEIGHT), _ => (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_LOGICAL_HEIGHT), } } @@ -2377,7 +2384,7 @@ mod tests { ); assert_eq!( onboarding_window_size(&onboarding::OnboardingStage::Intro), - (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_LOGICAL_HEIGHT), + (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_INTRO_HEIGHT), ); } From 847dc42905bd14d5cdbf7f3a572505926180ef88 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Wed, 17 Jun 2026 23:55:55 -0500 Subject: [PATCH 32/51] perf: keep the running hash across a pause so resume skips the re-read Signed-off-by: Logan Nguyen --- src-tauri/src/models/download.rs | 107 ++++++++++++++++++++++++++----- src-tauri/src/models/storage.rs | 78 +++++++++++++++++++++- 2 files changed, 169 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/models/download.rs b/src-tauri/src/models/download.rs index 4013b926..243aaf51 100644 --- a/src-tauri/src/models/download.rs +++ b/src-tauri/src/models/download.rs @@ -255,20 +255,28 @@ async fn fetch_into_partial( // must cover the full body and nothing that came before it. let mut hasher = Sha256::new(); if start > 0 { - // The resume re-hash reads the entire on-disk prefix back through - // SHA-256 to seed the running hash: seconds of blocking I/O on a - // multi-GB partial. Label it so the bar is not a silent frozen mystery. - emit(DownloadEvent::Verifying { - file: spec.file.clone(), - }); - // Cancellable so a pause during the re-hash lands instantly instead of - // after the whole prefix is read. A cancelled re-hash stops with a - // partial (discarded) hash; the cancel token is still set, so the - // stream loop below returns Cancelled at its first check before writing - // anything, keeping the on-disk partial intact for a later resume. - store - .feed_partial(&spec.sha256, &mut hasher, &|| cancel.is_cancelled()) - .map_err(DownloadIoError::Write)?; + match store.take_suspended_hash(&spec.sha256, start) { + // An in-session pause kept the running hash for this exact offset: + // continue it directly, skipping the prefix re-read entirely. + Some(suspended) => hasher = suspended, + // A cold resume (process restart, or no kept hash): rebuild the + // running hash by reading the on-disk prefix back through SHA-256. + // That re-read is seconds of blocking I/O on a multi-GB partial, so + // label it (Verifying) so the bar is not a silent frozen mystery, + // and make it cancellable so a pause during it lands instantly. A + // cancelled re-hash stops with a partial (discarded) hash; the + // cancel token is still set, so the stream loop below returns + // Cancelled at its first check before writing anything, keeping the + // on-disk partial intact for a later resume. + None => { + emit(DownloadEvent::Verifying { + file: spec.file.clone(), + }); + store + .feed_partial(&spec.sha256, &mut hasher, &|| cancel.is_cancelled()) + .map_err(DownloadIoError::Write)?; + } + } } let mut options = std::fs::OpenOptions::new(); @@ -291,7 +299,14 @@ async fn fetch_into_partial( // never emit Cancelled. The partial is kept for a later resume. let next = tokio::select! { biased; - () = cancel.cancelled() => return Ok(FetchOutcome::Cancelled), + () = cancel.cancelled() => { + // Keep the running hash so an in-session resume continues it + // instead of re-reading the prefix. `written` equals the + // on-disk length here (each chunk is written then hashed before + // the next cancel check), so the resume offset will match. + store.save_suspended_hash(&spec.sha256, written, hasher.clone()); + return Ok(FetchOutcome::Cancelled); + } next = stream.next() => next, }; let Some(chunk) = next else { break }; @@ -632,6 +647,64 @@ mod tests { ); } + #[tokio::test] + async fn resume_reuses_a_suspended_hash_and_skips_the_rehash() { + // An in-session resume where the running hash of the prefix was kept in + // memory (a pause). The re-read is skipped: no re-hash Verifying fires + // before the streamed bytes, and the continued hash still verifies. + let server = MockServer::start().await; + let body = body_of(8192); + let sha = sha256_of(&body); + Mock::given(method("GET")) + .and(path("/q/resolve/main/w.gguf")) + .and(header("range", "bytes=1000-")) + .respond_with(ResponseTemplate::new(206).set_body_bytes(body[1000..].to_vec())) + .mount(&server) + .await; + + let (_dir, store) = make_store(); + std::fs::write(store.partial_path(&sha), &body[..1000]).unwrap(); + // Stash the running hash of the prefix, as a pause would. + let mut prefix_hasher = Sha256::new(); + prefix_hasher.update(&body[..1000]); + store.save_suspended_hash(&sha, 1000, prefix_hasher); + + let spec = spec_for( + format!("{}/q/resolve/main/w.gguf", server.uri()), + "w.gguf", + &body, + ); + let (events, emit) = collector(); + let result = run_download( + &[spec], + &store, + &reqwest::Client::new(), + CancellationToken::new(), + emit, + ) + .await; + assert_eq!(result, Ok(())); + // The blob verifies, so the kept hash was continued correctly. + assert_eq!(std::fs::read(store.blob_path(&sha)).unwrap(), body); + + // The re-hash Verifying is gone: the only Verifying is the end verify, + // which comes AFTER the streamed Progress (the inverse of + // resume_emits_verifying_before_rehash). + let events = events.lock().unwrap(); + let first_progress = events + .iter() + .position(|e| matches!(e, DownloadEvent::Progress { .. })) + .unwrap(); + let first_verifying = events + .iter() + .position(|e| matches!(e, DownloadEvent::Verifying { .. })) + .unwrap(); + assert!( + first_progress < first_verifying, + "reusing the suspended hash must skip the re-hash Verifying" + ); + } + #[tokio::test] async fn range_ignored_by_server_restarts_from_scratch() { let server = MockServer::start().await; @@ -852,6 +925,10 @@ mod tests { // The partial was opened (and possibly fed the prefix) and is KEPT. assert!(store.existing_partial_len(&sha).is_some()); assert!(!store.blob_path(&sha).exists()); + // The running hash was stashed at the on-disk length so a resume can + // continue it without re-reading the prefix. + let len = store.existing_partial_len(&sha).unwrap(); + assert!(store.take_suspended_hash(&sha, len).is_some()); let _ = release_tx.send(()); server.await.unwrap(); } diff --git a/src-tauri/src/models/storage.rs b/src-tauri/src/models/storage.rs index fa307b6a..b607575f 100644 --- a/src-tauri/src/models/storage.rs +++ b/src-tauri/src/models/storage.rs @@ -18,6 +18,7 @@ use std::io; use std::path::PathBuf; +use std::sync::Mutex; use sha2::{Digest, Sha256}; @@ -34,6 +35,15 @@ pub enum StorageError { Io(#[from] io::Error), } +/// A paused download's running SHA-256, kept in memory so an in-session resume +/// can continue it instead of re-reading the whole on-disk prefix back through +/// SHA-256. `hasher` has consumed exactly `len` bytes of the partial `sha256`. +struct SuspendedHash { + sha256: String, + len: u64, + hasher: Sha256, +} + /// Content-addressed store rooted at a caller-supplied directory (in the app /// this is `/models`). /// @@ -42,6 +52,10 @@ pub enum StorageError { /// - `root/tmp/.partial`: in-flight downloads (resume-safe). pub struct ModelStore { root: PathBuf, + /// Running hash of the single in-flight download kept across a pause so an + /// in-session resume continues it rather than re-hashing the prefix. Holds + /// at most one entry (one download at a time); a later save overwrites it. + suspended_hash: Mutex>, } impl ModelStore { @@ -54,7 +68,31 @@ impl ModelStore { pub fn new(root: PathBuf) -> Result { std::fs::create_dir_all(root.join("blobs"))?; std::fs::create_dir_all(root.join("tmp"))?; - Ok(Self { root }) + Ok(Self { + root, + suspended_hash: Mutex::new(None), + }) + } + + /// Remembers a paused download's running `hasher` (which has consumed + /// exactly `len` bytes of the partial for `sha256`) so an in-session resume + /// can continue it. At most one is kept; a later save overwrites it. + pub fn save_suspended_hash(&self, sha256: &str, len: u64, hasher: Sha256) { + *self.suspended_hash.lock().unwrap() = Some(SuspendedHash { + sha256: sha256.to_string(), + len, + hasher, + }); + } + + /// Takes the kept running hash for `sha256` when it stands exactly at the + /// resume offset `len`. Clears the slot either way, so a stale entry never + /// lingers; returns the hasher to continue, or `None` to re-hash from disk. + pub fn take_suspended_hash(&self, sha256: &str, len: u64) -> Option { + match self.suspended_hash.lock().unwrap().take() { + Some(s) if s.sha256 == sha256 && s.len == len => Some(s.hasher), + _ => None, + } } /// Absolute path where a verified blob is stored: `root/blobs/`. @@ -321,6 +359,44 @@ mod tests { ); } + // ── suspended hash (in-memory resume) ──────────────────────────────────── + + #[test] + fn suspended_hash_round_trips_and_continues() { + let (_dir, store) = make_store(); + // A paused download whose running hash has consumed "abc". + let mut hasher = Sha256::new(); + hasher.update(b"abc"); + store.save_suspended_hash("aa", 3, hasher); + + // Resuming takes it back and continues with the remaining bytes; the + // result must equal hashing the whole stream in one pass. + let mut taken = store.take_suspended_hash("aa", 3).unwrap(); + taken.update(b"def"); + assert_eq!(format!("{:x}", taken.finalize()), sha256_of(b"abcdef")); + } + + #[test] + fn suspended_hash_take_clears_the_slot() { + let (_dir, store) = make_store(); + store.save_suspended_hash("aa", 3, Sha256::new()); + assert!(store.take_suspended_hash("aa", 3).is_some()); + // The slot is now empty: a second take finds nothing. + assert!(store.take_suspended_hash("aa", 3).is_none()); + } + + #[test] + fn suspended_hash_is_dropped_on_a_mismatch() { + let (_dir, store) = make_store(); + // A different sha clears the stale entry and returns None. + store.save_suspended_hash("aa", 3, Sha256::new()); + assert!(store.take_suspended_hash("bb", 3).is_none()); + assert!(store.take_suspended_hash("aa", 3).is_none()); + // A length that no longer matches the on-disk partial returns None. + store.save_suspended_hash("aa", 3, Sha256::new()); + assert!(store.take_suspended_hash("aa", 9).is_none()); + } + // ── remove_blobs ───────────────────────────────────────────────────────── #[test] From 13649b188d5dd06f8f70390d155d10b53791b4a6 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 11:11:31 -0500 Subject: [PATCH 33/51] fix: fit the intro window to its card so it never blocks background clicks Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 15 ++- .../__tests__/useFitOnboardingWindow.test.tsx | 91 +++++++++++++++++++ src/hooks/useFitOnboardingWindow.ts | 39 ++++++++ src/testUtils/mocks/tauri-window.ts | 1 + src/view/onboarding/IntroStep.tsx | 10 ++ 5 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 src/hooks/__tests__/useFitOnboardingWindow.test.tsx create mode 100644 src/hooks/useFitOnboardingWindow.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe0bdb7c..499183a4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -199,12 +199,6 @@ const ONBOARDING_EVENT: &str = "thuki://onboarding"; /// without extra transparent padding. const ONBOARDING_LOGICAL_WIDTH: f64 = 460.0; const ONBOARDING_LOGICAL_HEIGHT: f64 = 640.0; -/// The intro tour is taller than the other simple stages: it can carry the -/// ambient download strip pinned at the base of its card (a relaunch -/// mid-download lands here and auto-resumes), and the window is sized once at -/// show time, so it must reserve the strip's height up front or the strip -/// clips against the window's bottom edge. -const ONBOARDING_INTRO_HEIGHT: f64 = 720.0; const ONBOARDING_PICKER_WIDTH: f64 = 860.0; const ONBOARDING_PICKER_HEIGHT: f64 = 744.0; @@ -217,7 +211,10 @@ fn onboarding_window_size(stage: &onboarding::OnboardingStage) -> (f64, f64) { onboarding::OnboardingStage::ModelCheck => { (ONBOARDING_PICKER_WIDTH, ONBOARDING_PICKER_HEIGHT) } - onboarding::OnboardingStage::Intro => (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_INTRO_HEIGHT), + // The intro tour is sized to its card by the frontend + // (`useFitOnboardingWindow`) so the transparent window never blocks + // background clicks and grows to fit the ambient download strip; the + // compact base is only its pre-fit starting size. _ => (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_LOGICAL_HEIGHT), } } @@ -2382,9 +2379,11 @@ mod tests { onboarding_window_size(&onboarding::OnboardingStage::Permissions), (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_LOGICAL_HEIGHT), ); + // Intro falls back to the compact base; the frontend fits it to its + // card at runtime via `useFitOnboardingWindow`. assert_eq!( onboarding_window_size(&onboarding::OnboardingStage::Intro), - (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_INTRO_HEIGHT), + (ONBOARDING_LOGICAL_WIDTH, ONBOARDING_LOGICAL_HEIGHT), ); } diff --git a/src/hooks/__tests__/useFitOnboardingWindow.test.tsx b/src/hooks/__tests__/useFitOnboardingWindow.test.tsx new file mode 100644 index 00000000..7d74f4d3 --- /dev/null +++ b/src/hooks/__tests__/useFitOnboardingWindow.test.tsx @@ -0,0 +1,91 @@ +import { render } from '@testing-library/react'; +import { useRef } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { useFitOnboardingWindow } from '../useFitOnboardingWindow'; +import { __mockWindow } from '../../testUtils/mocks/tauri-window'; + +/** + * Renders the hook against a div whose measured box is stubbed to + * `width`/`height` (jsdom never computes layout). When `width`/`height` are + * undefined the node keeps its jsdom-default zero box; when `attach` is false + * the ref is never pointed at a node. + */ +function Harness({ + width, + height, + attach = true, + dep, +}: { + width?: number; + height?: number; + attach?: boolean; + dep?: unknown; +}) { + const ref = useRef(null); + useFitOnboardingWindow(ref, dep); + return ( +
{ + ref.current = attach ? node : null; + if (node && width !== undefined && height !== undefined) { + Object.defineProperty(node, 'offsetWidth', { + configurable: true, + value: width, + }); + Object.defineProperty(node, 'offsetHeight', { + configurable: true, + value: height, + }); + } + }} + /> + ); +} + +describe('useFitOnboardingWindow', () => { + beforeEach(() => { + __mockWindow.setSize.mockClear(); + __mockWindow.center.mockClear(); + }); + + it('sizes the window to the measured card box and re-centers', async () => { + render(); + await vi.waitFor(() => expect(__mockWindow.center).toHaveBeenCalled()); + + expect(__mockWindow.setSize).toHaveBeenCalledWith( + expect.objectContaining({ width: 474, height: 612 }), + ); + expect(__mockWindow.setSize).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the card has no measured box', () => { + render(); + expect(__mockWindow.setSize).not.toHaveBeenCalled(); + }); + + it('does nothing when the ref is not attached', () => { + render(); + expect(__mockWindow.setSize).not.toHaveBeenCalled(); + }); + + it('does nothing when only the height is unmeasured', () => { + render(); + expect(__mockWindow.setSize).not.toHaveBeenCalled(); + }); + + it('re-fits when a dependency changes (the strip grows the card)', async () => { + const { rerender } = render(); + await vi.waitFor(() => + expect(__mockWindow.setSize).toHaveBeenCalledTimes(1), + ); + + rerender(); + await vi.waitFor(() => + expect(__mockWindow.setSize).toHaveBeenCalledTimes(2), + ); + expect(__mockWindow.setSize).toHaveBeenLastCalledWith( + expect.objectContaining({ height: 660 }), + ); + }); +}); diff --git a/src/hooks/useFitOnboardingWindow.ts b/src/hooks/useFitOnboardingWindow.ts new file mode 100644 index 00000000..bb08227e --- /dev/null +++ b/src/hooks/useFitOnboardingWindow.ts @@ -0,0 +1,39 @@ +import { useLayoutEffect, type RefObject } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { LogicalSize } from '@tauri-apps/api/dpi'; + +/** + * Sizes the native onboarding window to exactly fit the measured content card, + * then re-centers it. + * + * The onboarding window is transparent, so any part of the window not covered + * by the visible card still captures mouse clicks meant for the apps behind + * Thuki. A fixed window taller than the card therefore leaves an invisible + * click-blocking margin. Measuring the card and matching the window to it + * removes that margin. The fit re-runs whenever `deps` change, so the window + * tracks the card as the ambient download strip appears or grows a line. + * + * Measurement uses `offsetWidth`/`offsetHeight` (the layout border box), which + * ignores the card's entrance transform, and runs in a layout effect so the + * resize happens before paint and the strip never flashes clipped. + * + * `changeKey` is any value that changes when the card height changes (the + * ambient download status). The fit re-runs whenever it changes identity. + */ +export function useFitOnboardingWindow( + ref: RefObject, + changeKey: unknown, +): void { + useLayoutEffect(() => { + const node = ref.current; + if (!node) return; + const width = node.offsetWidth; + const height = node.offsetHeight; + if (width === 0 || height === 0) return; + void (async () => { + const win = getCurrentWindow(); + await win.setSize(new LogicalSize(width, height)); + await win.center(); + })(); + }, [ref, changeKey]); +} diff --git a/src/testUtils/mocks/tauri-window.ts b/src/testUtils/mocks/tauri-window.ts index 70821555..f21be7d7 100644 --- a/src/testUtils/mocks/tauri-window.ts +++ b/src/testUtils/mocks/tauri-window.ts @@ -57,6 +57,7 @@ const mockWindow = { return mockLabel; }, setSize: vi.fn(async () => {}), + center: vi.fn(async () => {}), setPosition: vi.fn(async () => {}), hide: vi.fn(async () => {}), show: vi.fn(async () => {}), diff --git a/src/view/onboarding/IntroStep.tsx b/src/view/onboarding/IntroStep.tsx index 256b2f9a..14c142a6 100644 --- a/src/view/onboarding/IntroStep.tsx +++ b/src/view/onboarding/IntroStep.tsx @@ -1,6 +1,8 @@ +import { useRef } from 'react'; import { motion } from 'framer-motion'; import { invoke } from '@tauri-apps/api/core'; import thukiLogo from '../../../src-tauri/icons/128x128.png'; +import { useFitOnboardingWindow } from '../../hooks/useFitOnboardingWindow'; import { DownloadStatusStrip, type DownloadStripStatus, @@ -17,6 +19,13 @@ interface Props { } export function IntroStep({ onComplete, downloadStatus }: Props) { + const cardRef = useRef(null); + // Match the transparent window to the card so its empty area never blocks + // clicks to the apps behind Thuki. Re-fit when the ambient strip appears or + // changes height (the verifying line, the ready line) by keying on + // `downloadStatus`. + useFitOnboardingWindow(cardRef, downloadStatus); + const handleGetStarted = async () => { await invoke('finish_onboarding'); onComplete(); @@ -34,6 +43,7 @@ export function IntroStep({ onComplete, downloadStatus }: Props) { }} > Date: Thu, 18 Jun 2026 11:55:49 -0500 Subject: [PATCH 34/51] feat: name the model in the download strip, reassure on verify, and drop ambient Discard Signed-off-by: Logan Nguyen --- src/App.tsx | 16 ++- src/__tests__/App.test.tsx | 111 ++++++++++++++++-- src/components/DownloadStatusStrip.tsx | 62 +++++++--- .../__tests__/DownloadStatusStrip.test.tsx | 67 +++++++++-- src/contexts/DownloadContext.tsx | 12 +- .../__tests__/DownloadContext.test.tsx | 24 ---- src/view/__tests__/AskBarView.test.tsx | 5 +- .../onboarding/__tests__/IntroStep.test.tsx | 3 +- 8 files changed, 218 insertions(+), 82 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index eeb8091c..0f3e9273 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -435,11 +435,14 @@ function App() { isPaused: isDownloadPaused, isPausing: isDownloadPausing, pausedBytes: downloadPausedBytes, + activeOption: downloadActiveOption, pauseDownload, resumeFromPause, - discardActive, } = download; const downloadPhase = download.state.phase; + /** Display name of the model being downloaded, for the ambient strip. */ + const downloadModelName = + downloadActiveOption?.starter.display_name ?? 'your model'; const { capabilities: modelCapabilities, refresh: refreshModelCapabilities } = useModelCapabilities(); @@ -2453,7 +2456,6 @@ function App() { kind: 'paused', percent: percentOf(downloadPausedBytes), onResume: resumeFromPause, - onDiscard: discardActive, }; } // Transitional: Pause clicked but the cancel has not landed yet. Shown @@ -2461,7 +2463,11 @@ function App() { if (isDownloadPausing) { return { kind: 'pausing', percent: percentOf(liveBytes) }; } - if (downloadPhase === 'ready') return { kind: 'ready' }; + // The ready prompt invites the first message; it clears the instant the + // user sends one (enters chat mode) rather than lingering until a restart. + if (downloadPhase === 'ready') { + return isChatMode ? null : { kind: 'ready' }; + } if (downloadPhase === 'failed') { return { kind: 'failed', @@ -2487,6 +2493,7 @@ function App() { : null; return { kind: 'downloading', + modelName: downloadModelName, percent: percentOf(liveBytes), etaSeconds, onPause: pauseDownload, @@ -2498,6 +2505,8 @@ function App() { isDownloadPausing, downloadPausedBytes, downloadPhase, + downloadModelName, + isChatMode, downloadCombinedBytes, downloadResumeSeedBytes, downloadGrandTotalBytes, @@ -2505,7 +2514,6 @@ function App() { retryDownload, pauseDownload, resumeFromPause, - discardActive, ]); /** diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index a4465626..02189662 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -12,6 +12,7 @@ import { ConfigProviderForTest, } from '../contexts/ConfigContext'; import type { DownloadContextValue } from '../contexts/DownloadContext'; +import type { StarterOption } from '../types/starter'; import { invoke, emitTauriEvent, @@ -79,7 +80,6 @@ function makeDownloadCtx( pausedBytes: 0, pauseDownload: vi.fn(), resumeFromPause: vi.fn(), - discardActive: vi.fn(), ...overrides, }; } @@ -7809,9 +7809,10 @@ describe('App', () => { await showOverlay(); expect(screen.getByText('Verifying…')).toBeInTheDocument(); - // The strip owns the messaging: the downloading label is not shown. + // The strip owns the messaging: the downloading row (with its Pause) is + // not shown while verifying. expect( - screen.queryByText('Setting up your model'), + screen.queryByRole('button', { name: 'Pause download' }), ).not.toBeInTheDocument(); }); @@ -7903,13 +7904,102 @@ describe('App', () => { rerender(builtinTree()); }); - expect(screen.getByText('Model ready')).toBeInTheDocument(); + expect( + screen.getByText('Model ready. Send your first message'), + ).toBeInTheDocument(); const after = invoke.mock.calls.filter( (c) => c[0] === 'get_model_picker_state', ).length; expect(after).toBeGreaterThan(before); }); + function downloadingOption(displayName: string): StarterOption { + return { + starter: { + tier: 'fast', + display_name: displayName, + repo: 'org/repo-GGUF', + revision: 'a'.repeat(40), + file_name: 'model.gguf', + sha256: 'b'.repeat(64), + size_bytes: 5_000_000_000, + quant: 'Q4_K_M', + vision: true, + thinking: false, + mmproj_file: null, + mmproj_sha256: null, + mmproj_bytes: 0, + est_runtime_gb: 6, + license_note: 'Apache 2.0', + origin: 'Org', + origin_repo: 'org/repo', + }, + fit: 'fits', + installed: false, + partial_bytes: null, + }; + } + + it('names the model in the downloading strip from the active option', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: null, + all: [], + ollamaReachable: true, + }, + }); + downloadHolder.value = makeDownloadCtx({ + state: { phase: 'downloading' }, + activeOption: downloadingOption('Qwen3.5 9B'), + combinedBytes: 1_000_000_000, + grandTotalBytes: 10_000_000_000, + speedBytesPerSec: 5_000_000, + }); + + render(builtinTree()); + await act(async () => {}); + await showOverlay(); + + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); + }); + + it('dismisses the ready strip once the first message is sent', async () => { + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'm', + all: ['m'], + ollamaReachable: true, + }, + }); + downloadHolder.value = makeDownloadCtx({ state: { phase: 'ready' } }); + + render(builtinTree()); + await act(async () => {}); + await showOverlay(); + + expect( + screen.getByText('Model ready. Send your first message'), + ).toBeInTheDocument(); + + const textarea = getAskInput(); + act(() => { + setAskValue('hello'); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'hi' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + expect( + screen.queryByText('Model ready. Send your first message'), + ).not.toBeInTheDocument(); + }); + it('pauses the download from the ask-bar strip', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { @@ -7960,7 +8050,7 @@ describe('App', () => { expect(screen.getByText('Pausing…')).toBeInTheDocument(); }); - it('shows a paused strip with Resume / Discard and the held percent', async () => { + it('shows a paused strip with Resume and the held percent', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { active: null, @@ -7969,14 +8059,12 @@ describe('App', () => { }, }); const resumeFromPause = vi.fn(); - const discardActive = vi.fn(); downloadHolder.value = makeDownloadCtx({ state: { phase: 'idle' }, isPaused: true, pausedBytes: 5_000_000_000, grandTotalBytes: 10_000_000_000, resumeFromPause, - discardActive, }); const { rerender } = render(builtinTree()); @@ -7990,12 +8078,9 @@ describe('App', () => { ); }); expect(resumeFromPause).toHaveBeenCalledTimes(1); - await act(async () => { - fireEvent.click( - screen.getByRole('button', { name: 'Discard download' }), - ); - }); - expect(discardActive).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole('button', { name: 'Discard download' }), + ).not.toBeInTheDocument(); // Grand total unknown while paused falls back to 0%. downloadHolder.value = makeDownloadCtx({ diff --git a/src/components/DownloadStatusStrip.tsx b/src/components/DownloadStatusStrip.tsx index d01da541..671d57d6 100644 --- a/src/components/DownloadStatusStrip.tsx +++ b/src/components/DownloadStatusStrip.tsx @@ -8,12 +8,14 @@ * rather than a separate box. It is the only place the background download is * surfaced once the user has left the picker. */ -import type React from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; -/** The strip's four states, mirroring the download machine plus a paused hop. */ +/** The strip's states, mirroring the download machine plus a paused hop. */ export type DownloadStripStatus = | { kind: 'downloading'; + /** Display name of the model being downloaded, e.g. "Qwen3.5 9B". */ + modelName: string; percent: number; etaSeconds: number | null; onPause: () => void; @@ -22,13 +24,20 @@ export type DownloadStripStatus = kind: 'paused'; percent: number; onResume: () => void; - onDiscard: () => void; } | { kind: 'pausing'; percent: number } | { kind: 'verifying'; percent: number } | { kind: 'ready' } | { kind: 'failed'; message: string; onRetry: () => void }; +/** How often the downloading label swaps between the model name and the hint. */ +const LABEL_ROTATE_MS = 4000; +/** + * The reassurance half of the alternating label: the download keeps running in + * the background, so the user can dismiss Thuki (not quit) and come back. + */ +const BACKGROUND_HINT = 'Close anytime, runs in the background'; + const ORANGE = 'rgb(255,141,92)'; const ORANGE_FILL = 'linear-gradient(90deg,#ffa06f,#d45a1e)'; const MUTED = 'rgba(255,255,255,0.4)'; @@ -96,7 +105,7 @@ function Shell({ color: string; fill: string; percent: number; - children: React.ReactNode; + children: ReactNode; }) { return (
- Model ready + + Model ready. Send your first message + ); } @@ -162,15 +173,24 @@ export function DownloadStatusStrip({ if (status.kind === 'verifying') { // The integrity re-hash on resume (and the brief end-of-download verify): // an active working step, so it keeps the orange treatment but offers no - // controls of its own. + // controls of its own. The re-hash of a multi-GB partial is a slow read, so + // the sub-line reassures the user it is working rather than hung. return ( - Verifying… + + Verifying… + + This can take a minute for large models + + ); } if (status.kind === 'paused') { + // Resume only here. Discard belongs to the picker, where a Download button + // can re-trigger; in the ambient strip a discard would strand the user with + // no way back to start a download. return ( Paused · {status.percent}% @@ -180,23 +200,37 @@ export function DownloadStatusStrip({ color={ACTION} onClick={status.onResume} /> - ); } + return ; +} + +/** + * The byte-moving downloading row. Its label alternates between the model name + * and the "runs in the background" reassurance so both fit the single line; the + * percent, ETA, and Pause stay fixed. + */ +function DownloadingRow({ + status, +}: { + status: Extract; +}) { + const [showHint, setShowHint] = useState(false); + useEffect(() => { + const id = setInterval(() => setShowHint((s) => !s), LABEL_ROTATE_MS); + return () => clearInterval(id); + }, []); + + const label = showHint ? BACKGROUND_HINT : `Downloading ${status.modelName}`; const trailing = status.etaSeconds !== null ? `${status.percent}% · ${formatEta(status.etaSeconds)} left` : `${status.percent}%`; return ( - Setting up your model + {label} {trailing} { - it('shows the setup label, percent and ETA while downloading', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows the model name, percent and ETA while downloading', () => { render( , ); - expect(screen.getByText('Setting up your model')).toBeInTheDocument(); + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); expect(screen.getByText('62% · 1m left')).toBeInTheDocument(); }); + it('alternates the label with the background hint every few seconds', () => { + vi.useFakeTimers(); + render( + , + ); + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(4000)); + expect( + screen.getByText('Close anytime, runs in the background'), + ).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(4000)); + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); + }); + it('omits the ETA when it is not yet measurable', () => { render( { { { const onPause = vi.fn(); render( , ); fireEvent.click(screen.getByRole('button', { name: 'Pause download' })); @@ -74,30 +110,35 @@ describe('DownloadStatusStrip', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - it('shows a paused state with Resume and Discard', () => { + it('shows a paused state with Resume but no Discard', () => { const onResume = vi.fn(); - const onDiscard = vi.fn(); render( , ); expect(screen.getByText('Paused · 58%')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Resume download' })); expect(onResume).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole('button', { name: 'Discard download' })); - expect(onDiscard).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole('button', { name: 'Discard download' }), + ).not.toBeInTheDocument(); }); - it('shows a verifying state (no controls) during the re-hash', () => { + it('reassures that verifying can take a while during the re-hash', () => { render(); expect(screen.getByText('Verifying…')).toBeInTheDocument(); + expect( + screen.getByText('This can take a minute for large models'), + ).toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - it('shows a ready state', () => { + it('shows a ready state that invites the first message', () => { render(); - expect(screen.getByText('Model ready')).toBeInTheDocument(); + expect( + screen.getByText('Model ready. Send your first message'), + ).toBeInTheDocument(); }); it('shows a failure message with a Retry button', () => { diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index a865158f..b7647e5f 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -76,8 +76,6 @@ export interface DownloadContextValue extends UseDownloadModel { pauseDownload: () => void; /** Resume a paused download from where it stopped. */ resumeFromPause: () => void; - /** Discard a paused download's partial and clear the active option. */ - discardActive: () => void; } const DownloadContext = createContext(null); @@ -92,7 +90,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { const [pauseRequested, setPauseRequested] = useState(false); const [pausedBytes, setPausedBytes] = useState(0); - const { start, resume, cancel, discard, combinedBytes } = download; + const { start, resume, cancel, combinedBytes } = download; const downloadPhase = download.state.phase; // A pause is only *committed* once the cancel has fully landed (machine back @@ -168,12 +166,6 @@ export function DownloadProvider({ children }: { children: ReactNode }) { resumeDownload(activeOption!.starter.tier, activeOption!, pausedBytes); }, [activeOption, pausedBytes, resumeDownload]); - const discardActive = useCallback(() => { - setPauseRequested(false); - void discard(activeOption!.starter.sha256); - setActiveOption(null); - }, [activeOption, discard]); - const grandTotalBytes = activeOption === null ? null @@ -193,7 +185,6 @@ export function DownloadProvider({ children }: { children: ReactNode }) { pausedBytes, pauseDownload, resumeFromPause, - discardActive, }), [ download, @@ -208,7 +199,6 @@ export function DownloadProvider({ children }: { children: ReactNode }) { pausedBytes, pauseDownload, resumeFromPause, - discardActive, ], ); diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx index 8583fcfb..39972e72 100644 --- a/src/contexts/__tests__/DownloadContext.test.tsx +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -234,30 +234,6 @@ describe('DownloadContext', () => { ).toHaveLength(2); }); - it('discardActive discards the partial and clears the active option', async () => { - const { result } = renderHook(() => useDownloadCtx(), { wrapper }); - const opt = option({ sha256: 'deadbeef' }); - - await act(async () => { - result.current.beginDownload('balanced', opt); - }); - await act(async () => { - result.current.pauseDownload(); - }); - act(() => channel().simulateMessage({ type: 'Cancelled' })); - - await act(async () => { - result.current.discardActive(); - }); - - expect(result.current.isPaused).toBe(false); - expect(result.current.activeOption).toBeNull(); - expect(result.current.grandTotalBytes).toBeNull(); - expect(invoke).toHaveBeenCalledWith('discard_partial_download', { - sha256: 'deadbeef', - }); - }); - describe('launch auto-resume', () => { it('resumes an interrupted partial past the picker (intro) with no installed model', async () => { const partial: StarterOption = { diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx index 2d49b2f8..6612f47a 100644 --- a/src/view/__tests__/AskBarView.test.tsx +++ b/src/view/__tests__/AskBarView.test.tsx @@ -115,6 +115,7 @@ describe('AskBarView', () => { inputRef={makeRef()} downloadStatus={{ kind: 'downloading', + modelName: 'Qwen3.5 9B', percent: 40, etaSeconds: 90, onPause: vi.fn(), @@ -122,7 +123,7 @@ describe('AskBarView', () => { />, ); expect(screen.getByTestId('download-status-strip')).toBeInTheDocument(); - expect(screen.getByText('Setting up your model')).toBeInTheDocument(); + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); }); it('renders no download strip when no download status is supplied', () => { @@ -157,6 +158,7 @@ describe('AskBarView', () => { inputRef={makeRef()} downloadStatus={{ kind: 'downloading', + modelName: 'Qwen3.5 9B', percent: 58, etaSeconds: 180, onPause: vi.fn(), @@ -198,7 +200,6 @@ describe('AskBarView', () => { kind: 'paused', percent: 58, onResume: vi.fn(), - onDiscard: vi.fn(), }} />, ); diff --git a/src/view/onboarding/__tests__/IntroStep.test.tsx b/src/view/onboarding/__tests__/IntroStep.test.tsx index 5ce34361..dd2b72d1 100644 --- a/src/view/onboarding/__tests__/IntroStep.test.tsx +++ b/src/view/onboarding/__tests__/IntroStep.test.tsx @@ -60,6 +60,7 @@ describe('IntroStep', () => { onComplete={vi.fn()} downloadStatus={{ kind: 'downloading', + modelName: 'Qwen3.5 9B', percent: 15, etaSeconds: 180, onPause: vi.fn(), @@ -67,7 +68,7 @@ describe('IntroStep', () => { />, ); expect(screen.getByTestId('download-status-strip')).toBeInTheDocument(); - expect(screen.getByText('Setting up your model')).toBeInTheDocument(); + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); }); it('renders no download strip when no status is supplied', () => { From 71842a182bc53aa9fd0b5c97598cc9604328bf1d Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 12:07:14 -0500 Subject: [PATCH 35/51] feat: show the real reason a model download failed Signed-off-by: Logan Nguyen --- src/App.tsx | 13 ++++++++---- src/__tests__/App.test.tsx | 4 ++-- src/hooks/__tests__/useDownloadModel.test.tsx | 18 ++++++++++++++++ src/hooks/useDownloadModel.ts | 21 +++++++++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0f3e9273..e12d60aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,10 @@ import { useConversationHistory } from './hooks/useConversationHistory'; import { useModelSelection } from './hooks/useModelSelection'; import { useModelCapabilities } from './hooks/useModelCapabilities'; import { useDownloadCtx } from './contexts/DownloadContext'; -import { isDownloadInFlight } from './hooks/useDownloadModel'; +import { + downloadFailureMessage, + isDownloadInFlight, +} from './hooks/useDownloadModel'; import type { DownloadStripStatus } from './components/DownloadStatusStrip'; import { getCapabilityConflict, @@ -439,7 +442,8 @@ function App() { pauseDownload, resumeFromPause, } = download; - const downloadPhase = download.state.phase; + const downloadState = download.state; + const downloadPhase = downloadState.phase; /** Display name of the model being downloaded, for the ambient strip. */ const downloadModelName = downloadActiveOption?.starter.display_name ?? 'your model'; @@ -2468,10 +2472,10 @@ function App() { if (downloadPhase === 'ready') { return isChatMode ? null : { kind: 'ready' }; } - if (downloadPhase === 'failed') { + if (downloadState.phase === 'failed') { return { kind: 'failed', - message: 'Model download failed.', + message: downloadFailureMessage(downloadState.kind), onRetry: () => void retryDownload(), }; } @@ -2505,6 +2509,7 @@ function App() { isDownloadPausing, downloadPausedBytes, downloadPhase, + downloadState, downloadModelName, isChatMode, downloadCombinedBytes, diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 02189662..8774fd49 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -7852,7 +7852,7 @@ describe('App', () => { expect(screen.getByTestId('download-status-strip')).toBeInTheDocument(); }); - it('shows a failed strip whose Retry restarts the download', async () => { + it('shows the real failure reason and a Retry that restarts the download', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { active: null, @@ -7870,7 +7870,7 @@ describe('App', () => { await act(async () => {}); await showOverlay(); - expect(screen.getByText('Model download failed.')).toBeInTheDocument(); + expect(screen.getByText('You appear to be offline.')).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Retry download' })); }); diff --git a/src/hooks/__tests__/useDownloadModel.test.tsx b/src/hooks/__tests__/useDownloadModel.test.tsx index 42b76f1e..7ba24e82 100644 --- a/src/hooks/__tests__/useDownloadModel.test.tsx +++ b/src/hooks/__tests__/useDownloadModel.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { computeEtaSeconds, computeSpeedBytesPerSec, + downloadFailureMessage, isDownloadInFlight, useDownloadModel, } from '../useDownloadModel'; @@ -798,3 +799,20 @@ describe('isDownloadInFlight', () => { } }); }); + +describe('downloadFailureMessage', () => { + it('maps each failure kind to a friendly, jargon-free reason', () => { + expect(downloadFailureMessage('offline')).toBe('You appear to be offline.'); + expect(downloadFailureMessage('http')).toBe( + 'Hugging Face had an error. Try again.', + ); + expect(downloadFailureMessage('checksum')).toBe( + 'The download did not verify. Retrying starts it fresh.', + ); + expect(downloadFailureMessage('disk_full')).toBe('Not enough disk space.'); + expect(downloadFailureMessage('engine')).toBe( + "Thuki's engine could not start.", + ); + expect(downloadFailureMessage('other')).toBe('Model download failed.'); + }); +}); diff --git a/src/hooks/useDownloadModel.ts b/src/hooks/useDownloadModel.ts index cf85daa3..8dc51454 100644 --- a/src/hooks/useDownloadModel.ts +++ b/src/hooks/useDownloadModel.ts @@ -63,6 +63,27 @@ export function isDownloadInFlight(phase: DownloadUiState['phase']): boolean { ); } +/** + * A short, jargon-free reason for a failed download, by kind, so the ambient + * strip tells the user what actually went wrong instead of a generic message. + */ +export function downloadFailureMessage(kind: DownloadUiFailKind): string { + switch (kind) { + case 'offline': + return 'You appear to be offline.'; + case 'http': + return 'Hugging Face had an error. Try again.'; + case 'checksum': + return 'The download did not verify. Retrying starts it fresh.'; + case 'disk_full': + return 'Not enough disk space.'; + case 'engine': + return "Thuki's engine could not start."; + case 'other': + return 'Model download failed.'; + } +} + /** Last reported byte counts for the file currently downloading. */ export interface DownloadProgressInfo { file: string; From 7309885a85b8cec8665de7f4bb3e4283467ccf18 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 12:19:21 -0500 Subject: [PATCH 36/51] feat: show friendly model names in the picker instead of raw repo:file ids Signed-off-by: Logan Nguyen --- src-tauri/src/models/mod.rs | 64 ++++++++++++++++--- src/App.tsx | 3 + src/components/ModelPickerPanel.tsx | 30 +++++++-- .../__tests__/ModelPickerPanel.test.tsx | 42 ++++++++++++ .../__tests__/useModelSelection.test.tsx | 29 +++++++++ src/hooks/useModelSelection.ts | 12 ++++ src/types/model.ts | 7 ++ 7 files changed, 171 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index d92ab2ce..33e3a777 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -319,11 +319,14 @@ pub async fn get_model_picker_state( db: tauri::State<'_, crate::history::Database>, ) -> Result { let (base_url, active_id, persisted, kind) = read_provider_model_context(&config); - let manifest_ids = if kind == PROVIDER_KIND_BUILTIN { - manifest_model_ids(&db)? + let manifest_rows = if kind == PROVIDER_KIND_BUILTIN { + let conn = db.0.lock().map_err(|e| e.to_string())?; + manifest::list(&conn).map_err(|e| e.to_string())? } else { Vec::new() }; + let manifest_ids: Vec = manifest_rows.iter().map(|m| m.id.clone()).collect(); + let display_names = manifest_displays_map(&manifest_rows); let (installed, reachable) = picker_inventory_for_kind( &client, &kind, @@ -349,6 +352,7 @@ pub async fn get_model_picker_state( resolved.as_deref(), &installed, reachable, + &display_names, )) } @@ -447,6 +451,7 @@ pub fn build_picker_state_payload( active: Option<&str>, installed: &[String], ollama_reachable: bool, + display_names: &HashMap, ) -> serde_json::Value { let active_value = match active { Some(slug) => serde_json::Value::String(slug.to_string()), @@ -456,9 +461,21 @@ pub fn build_picker_state_payload( "active": active_value, "all": installed, "ollamaReachable": ollama_reachable, + // id -> friendly display name; populated for built-in models (whose ids + // are "repo:file.gguf"), empty for Ollama/OpenAI whose ids already read + // cleanly. The frontend falls back to the id when an entry is missing. + "displayNames": display_names, }) } +/// Maps each installed model's id to its recorded display name, for the picker +/// to show "Qwen3.5 9B" instead of the raw "repo:file.gguf" id. +fn manifest_displays_map(rows: &[manifest::InstalledModel]) -> HashMap { + rows.iter() + .map(|m| (m.id.clone(), m.display_name.clone())) + .collect() +} + /// Persists `model` as the active model after validating its shape and /// confirming the active provider still serves it. The validation source is /// routed by provider kind exactly like [`picker_inventory_for_kind`]: the @@ -1956,10 +1973,11 @@ mod tests { // S1 mirrors the unreachable case: no model can be resolved, the // installed list is empty by definition, and the flag is false so // the frontend can pick the right strip copy. - let payload = build_picker_state_payload(None, &[], false); + let payload = build_picker_state_payload(None, &[], false, &HashMap::new()); assert_eq!(payload["active"], serde_json::Value::Null); assert_eq!(payload["all"], serde_json::json!([])); assert_eq!(payload["ollamaReachable"], serde_json::Value::Bool(false)); + assert_eq!(payload["displayNames"], serde_json::json!({})); } #[test] @@ -1967,24 +1985,50 @@ mod tests { // S2: Ollama responded but installed list is empty. Active is null // (nothing to resolve to) yet ollamaReachable is true so the strip // can tell the user to pull a model rather than start the daemon. - let payload = build_picker_state_payload(None, &[], true); + let payload = build_picker_state_payload(None, &[], true, &HashMap::new()); assert_eq!(payload["active"], serde_json::Value::Null); assert_eq!(payload["all"], serde_json::json!([])); assert_eq!(payload["ollamaReachable"], serde_json::Value::Bool(true)); + assert_eq!(payload["displayNames"], serde_json::json!({})); } #[test] - fn picker_payload_reachable_with_models_carries_active_slug() { + fn picker_payload_reachable_with_models_carries_active_slug_and_display_names() { // S4 (normal): active slug is present and ollamaReachable is true. - // The frontend renders the chip with the slug and skips the strip. - let installed = vec!["gemma4:e2b".to_string(), "gemma4:e4b".to_string()]; - let payload = build_picker_state_payload(Some("gemma4:e4b"), &installed, true); - assert_eq!(payload["active"], serde_json::json!("gemma4:e4b")); + // Built-in ids carry a friendly display name so the picker shows + // "Qwen3.5 9B" rather than the raw "repo:file.gguf" slug. + let installed = vec!["org/repo:a.gguf".to_string(), "org/repo:b.gguf".to_string()]; + let displays = HashMap::from([ + ("org/repo:a.gguf".to_string(), "Model A".to_string()), + ("org/repo:b.gguf".to_string(), "Model B".to_string()), + ]); + let payload = + build_picker_state_payload(Some("org/repo:b.gguf"), &installed, true, &displays); + assert_eq!(payload["active"], serde_json::json!("org/repo:b.gguf")); assert_eq!( payload["all"], - serde_json::json!(["gemma4:e2b", "gemma4:e4b"]) + serde_json::json!(["org/repo:a.gguf", "org/repo:b.gguf"]) ); assert_eq!(payload["ollamaReachable"], serde_json::Value::Bool(true)); + assert_eq!(payload["displayNames"]["org/repo:a.gguf"], "Model A"); + assert_eq!(payload["displayNames"]["org/repo:b.gguf"], "Model B"); + } + + #[test] + fn manifest_displays_map_keys_ids_to_display_names() { + let rows = vec![ + manifest_row("org/repo:a.gguf", true, false), + manifest_row("org/repo:b.gguf", false, false), + ]; + let map = manifest_displays_map(&rows); + assert_eq!( + map.get("org/repo:a.gguf").map(String::as_str), + Some("Model org/repo:a.gguf") + ); + assert_eq!( + map.get("org/repo:b.gguf").map(String::as_str), + Some("Model org/repo:b.gguf") + ); } // ── picker_inventory_for_kind ──────────────────────────────────────────── diff --git a/src/App.tsx b/src/App.tsx index e12d60aa..54d350fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -418,6 +418,7 @@ function App() { const { activeModel, availableModels, + modelDisplayNames, ollamaReachable, refreshModels, setActiveModel, @@ -3566,6 +3567,7 @@ function App() { providerKind={ config.inference.activeProviderKind } + displayNames={modelDisplayNames} /> ) : null} @@ -3784,6 +3786,7 @@ function App() { onClose={handleModelPickerClose} capabilities={modelCapabilities} providerKind={config.inference.activeProviderKind} + displayNames={modelDisplayNames} compact /> diff --git a/src/components/ModelPickerPanel.tsx b/src/components/ModelPickerPanel.tsx index 4776a1bb..d163e004 100644 --- a/src/components/ModelPickerPanel.tsx +++ b/src/components/ModelPickerPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import type { ModelCapabilitiesMap } from '../types/model'; import { @@ -95,6 +95,12 @@ export interface ModelPickerPanelProps { * ConfigContext's fallback for an unresolvable provider. */ providerKind?: string; + /** + * Friendly display name per model id. Rows render the display name when an + * id has one (built-in models) and fall back to the id otherwise (Ollama / + * OpenAI). Selection and keys still use the id. + */ + displayNames?: Record; } /** @@ -113,17 +119,29 @@ export function ModelPickerPanel({ capabilities, compact = false, providerKind = 'ollama', + displayNames, }: ModelPickerPanelProps) { const [filter, setFilter] = useState(''); const [highlightedIndex, setHighlightedIndex] = useState(0); const listboxRef = useRef(null); + /** The user-facing label for a model id: its display name, else the id. */ + const labelFor = useCallback( + (model: string): string => displayNames?.[model] ?? model, + [displayNames], + ); + const filtered = useMemo(() => { const trimmed = filter.trim(); if (trimmed === '') return models; const needle = trimmed.toLowerCase(); - return models.filter((m) => m.toLowerCase().includes(needle)); - }, [filter, models]); + // Match the id or its friendly label so search works on what is shown. + return models.filter( + (m) => + m.toLowerCase().includes(needle) || + labelFor(m).toLowerCase().includes(needle), + ); + }, [filter, models, labelFor]); // Inline clamp: derive the safe render index without a useEffect so // aria-activedescendant is consistent on the same render that filtered shrinks. @@ -281,8 +299,8 @@ export function ModelPickerPanel({ aria-selected={active} aria-label={ capLabel - ? `${model}, ${capLabel.replace(/ · /g, ', ')}` - : model + ? `${labelFor(model)}, ${capLabel.replace(/ · /g, ', ')}` + : labelFor(model) } tabIndex={-1} onMouseEnter={() => setHighlightedIndex(index)} @@ -293,7 +311,7 @@ export function ModelPickerPanel({ > - {model} + {labelFor(model)} {capLabel && ( { } }); + const BUILTIN_ID = 'unsloth/Qwen3.5-9B-GGUF:Qwen3.5-9B-Q4_K_M.gguf'; + + it('renders the friendly display name for ids that have one', () => { + renderPanel({ + models: [BUILTIN_ID], + activeModel: null, + displayNames: { [BUILTIN_ID]: 'Qwen3.5 9B' }, + }); + expect( + screen.getByRole('option', { name: 'Qwen3.5 9B' }), + ).toBeInTheDocument(); + expect(screen.queryByText(BUILTIN_ID)).not.toBeInTheDocument(); + }); + + it('falls back to the id when no display name is given', () => { + renderPanel({ + models: ['llama3.2:3b'], + activeModel: null, + displayNames: {}, + }); + expect( + screen.getByRole('option', { name: 'llama3.2:3b' }), + ).toBeInTheDocument(); + }); + + it('filters by the friendly display name, not just the id', () => { + renderPanel({ + models: [BUILTIN_ID, 'llama3.2:3b'], + activeModel: null, + displayNames: { [BUILTIN_ID]: 'Qwen3.5 9B' }, + }); + fireEvent.change(screen.getByPlaceholderText(/filter models/i), { + target: { value: 'qwen3.5 9b' }, + }); + expect( + screen.getByRole('option', { name: 'Qwen3.5 9B' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('option', { name: 'llama3.2:3b' }), + ).not.toBeInTheDocument(); + }); + it('marks active model with aria-selected true, others false', () => { renderPanel({ activeModel: 'qwen2.5:7b' }); expect(screen.getByRole('option', { name: 'qwen2.5:7b' })).toHaveAttribute( diff --git a/src/hooks/__tests__/useModelSelection.test.tsx b/src/hooks/__tests__/useModelSelection.test.tsx index 8f3a7a48..99ec2a16 100644 --- a/src/hooks/__tests__/useModelSelection.test.tsx +++ b/src/hooks/__tests__/useModelSelection.test.tsx @@ -26,6 +26,35 @@ describe('useModelSelection', () => { expect(result.current.ollamaReachable).toBe(true); }); + it('exposes per-id display names from the backend payload', async () => { + invoke.mockResolvedValueOnce({ + active: 'org/repo:a.gguf', + all: ['org/repo:a.gguf'], + ollamaReachable: true, + displayNames: { 'org/repo:a.gguf': 'Model A' }, + }); + + const { result } = renderHook(() => useModelSelection()); + await act(async () => {}); + + expect(result.current.modelDisplayNames).toEqual({ + 'org/repo:a.gguf': 'Model A', + }); + }); + + it('defaults display names to an empty map when the payload omits them', async () => { + invoke.mockResolvedValueOnce({ + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }); + + const { result } = renderHook(() => useModelSelection()); + await act(async () => {}); + + expect(result.current.modelDisplayNames).toEqual({}); + }); + it('starts with a null active model before the first refresh resolves', () => { invoke.mockImplementationOnce(() => new Promise(() => {})); const { result } = renderHook(() => useModelSelection()); diff --git a/src/hooks/useModelSelection.ts b/src/hooks/useModelSelection.ts index 610db442..fd145aca 100644 --- a/src/hooks/useModelSelection.ts +++ b/src/hooks/useModelSelection.ts @@ -39,6 +39,11 @@ export interface UseModelSelectionResult { activeModel: string | null; /** All locally installed Ollama model names available for selection. */ availableModels: string[]; + /** + * Friendly display name per model id (built-in models only); ids without an + * entry render verbatim. Drives the picker's elegant labels. + */ + modelDisplayNames: Record; /** * Whether the most recent backend call reached the local Ollama daemon. * `true` is the optimistic default before the first fetch resolves so the @@ -80,6 +85,9 @@ export function useModelSelection(): UseModelSelectionResult { // eslint-disable-next-line @eslint-react/use-state const [activeModel, setActiveModelState] = useState(null); const [availableModels, setAvailableModels] = useState([]); + const [modelDisplayNames, setModelDisplayNames] = useState< + Record + >({}); // Optimistic default: assume reachable until the first fetch tells us // otherwise. This prevents a cold-start flash of the "Ollama is down" // strip while the IPC call is in flight. @@ -111,16 +119,19 @@ export function useModelSelection(): UseModelSelectionResult { // is unreachable so the strip nudges the user toward starting it. setActiveModelState(null); setAvailableModels([]); + setModelDisplayNames({}); setOllamaReachable(false); return; } setActiveModelState(state.active); setAvailableModels(state.all); + setModelDisplayNames(state.displayNames ?? {}); setOllamaReachable(state.ollamaReachable); } catch { if (!isLatest(token)) return; setActiveModelState(null); setAvailableModels([]); + setModelDisplayNames({}); setOllamaReachable(false); } }, [isLatest]); @@ -151,6 +162,7 @@ export function useModelSelection(): UseModelSelectionResult { return { activeModel, availableModels, + modelDisplayNames, ollamaReachable, refreshModels, setActiveModel, diff --git a/src/types/model.ts b/src/types/model.ts index cdd0c8fa..db535e14 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -15,6 +15,13 @@ export interface ModelPickerState { active: string | null; /** All locally installed Ollama model names available for selection. */ all: string[]; + /** + * Friendly display name per model id, for built-in models whose ids are the + * raw "repo:file.gguf" slug (e.g. "...:Qwen3.5-9B-Q4_K_M.gguf" -> "Qwen3.5 + * 9B"). Sparse: omitted/absent ids fall back to rendering the id verbatim, + * which is already clean for Ollama and OpenAI providers. + */ + displayNames?: Record; /** * Whether the Rust backend successfully reached the local Ollama daemon * during the last picker fetch. False when `/api/tags` errored (connection From 32dbc03f14b3a0f6600c5fac7ee555c78dcf89bc Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 12:58:11 -0500 Subject: [PATCH 37/51] feat: soften the download label rotation and make the ready nudge a one-time, named prompt Signed-off-by: Logan Nguyen --- src/App.tsx | 19 +++++++--- src/__tests__/App.test.tsx | 27 ++++++++++---- src/components/DownloadStatusStrip.tsx | 35 ++++++++++++++----- .../__tests__/DownloadStatusStrip.test.tsx | 16 +++++---- src/view/__tests__/AskBarView.test.tsx | 2 +- 5 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 54d350fe..2ed9d3be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -682,6 +682,14 @@ function App() { const isChatMode = messages.length > 0 || isGenerating || isSubmitPending; const previousIsChatModeRef = useRef(isChatMode); + // The "model ready, send your first message" nudge is a one-time prompt. Once + // the user has sent any message (entered chat mode), it is acknowledged for + // good, so it never reappears on a new conversation or the next summon. + const [readyNudgeAcknowledged, setReadyNudgeAcknowledged] = useState(false); + useEffect(() => { + if (isChatMode) setReadyNudgeAcknowledged(true); + }, [isChatMode]); + /** * The bookmark save button is active once the AI has produced at least one * complete response. We check for an assistant message rather than any message @@ -2468,10 +2476,13 @@ function App() { if (isDownloadPausing) { return { kind: 'pausing', percent: percentOf(liveBytes) }; } - // The ready prompt invites the first message; it clears the instant the - // user sends one (enters chat mode) rather than lingering until a restart. + // The ready prompt invites the first message; once acknowledged (the user + // has sent a message) it never reappears, including on a new conversation + // or the next summon. if (downloadPhase === 'ready') { - return isChatMode ? null : { kind: 'ready' }; + return readyNudgeAcknowledged + ? null + : { kind: 'ready', modelName: downloadModelName }; } if (downloadState.phase === 'failed') { return { @@ -2512,7 +2523,7 @@ function App() { downloadPhase, downloadState, downloadModelName, - isChatMode, + readyNudgeAcknowledged, downloadCombinedBytes, downloadResumeSeedBytes, downloadGrandTotalBytes, diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 8774fd49..b6a0db22 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -7899,13 +7899,16 @@ describe('App', () => { const before = invoke.mock.calls.filter( (c) => c[0] === 'get_model_picker_state', ).length; - downloadHolder.value = makeDownloadCtx({ state: { phase: 'ready' } }); + downloadHolder.value = makeDownloadCtx({ + state: { phase: 'ready' }, + activeOption: downloadingOption('Qwen3.5 9B'), + }); await act(async () => { rerender(builtinTree()); }); expect( - screen.getByText('Model ready. Send your first message'), + screen.getByText('Qwen3.5 9B ready. Send your first message!'), ).toBeInTheDocument(); const after = invoke.mock.calls.filter( (c) => c[0] === 'get_model_picker_state', @@ -7963,7 +7966,7 @@ describe('App', () => { expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); }); - it('dismisses the ready strip once the first message is sent', async () => { + it('dismisses the ready nudge after the first message and never reshows it', async () => { enableChannelCaptureWithResponses({ get_model_picker_state: { active: 'm', @@ -7971,14 +7974,17 @@ describe('App', () => { ollamaReachable: true, }, }); - downloadHolder.value = makeDownloadCtx({ state: { phase: 'ready' } }); + downloadHolder.value = makeDownloadCtx({ + state: { phase: 'ready' }, + activeOption: downloadingOption('Qwen3.5 9B'), + }); render(builtinTree()); await act(async () => {}); await showOverlay(); expect( - screen.getByText('Model ready. Send your first message'), + screen.getByText('Qwen3.5 9B ready. Send your first message!'), ).toBeInTheDocument(); const textarea = getAskInput(); @@ -7996,7 +8002,16 @@ describe('App', () => { await act(async () => {}); expect( - screen.queryByText('Model ready. Send your first message'), + screen.queryByText('Qwen3.5 9B ready. Send your first message!'), + ).not.toBeInTheDocument(); + + // Back out of chat mode (new conversation / re-summon clears messages): + // the nudge stays dismissed, it is a one-time prompt. + await act(async () => { + await showOverlay(); + }); + expect( + screen.queryByText('Qwen3.5 9B ready. Send your first message!'), ).not.toBeInTheDocument(); }); diff --git a/src/components/DownloadStatusStrip.tsx b/src/components/DownloadStatusStrip.tsx index 671d57d6..c9607d0a 100644 --- a/src/components/DownloadStatusStrip.tsx +++ b/src/components/DownloadStatusStrip.tsx @@ -9,6 +9,7 @@ * surfaced once the user has left the picker. */ import { useEffect, useState, type ReactNode } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; /** The strip's states, mirroring the download machine plus a paused hop. */ export type DownloadStripStatus = @@ -27,16 +28,21 @@ export type DownloadStripStatus = } | { kind: 'pausing'; percent: number } | { kind: 'verifying'; percent: number } - | { kind: 'ready' } + | { kind: 'ready'; modelName: string } | { kind: 'failed'; message: string; onRetry: () => void }; -/** How often the downloading label swaps between the model name and the hint. */ -const LABEL_ROTATE_MS = 4000; /** - * The reassurance half of the alternating label: the download keeps running in - * the background, so the user can dismiss Thuki (not quit) and come back. + * How long each half of the downloading label shows before crossfading to the + * other. Slow on purpose: the strip is ambient, so the swap should be a calm + * background rhythm, not something that pulls the eye. */ -const BACKGROUND_HINT = 'Close anytime, runs in the background'; +const LABEL_ROTATE_MS = 7000; +/** + * The reassurance half of the alternating label: the download keeps going while + * Thuki is closed (hidden), so the user can step away and come back. "Close" + * (not quit) is deliberate: quitting from the tray is what stops it. + */ +const BACKGROUND_HINT = 'You can close and come back anytime'; const ORANGE = 'rgb(255,141,92)'; const ORANGE_FILL = 'linear-gradient(90deg,#ffa06f,#d45a1e)'; @@ -142,7 +148,7 @@ export function DownloadStatusStrip({ return ( - Model ready. Send your first message + {status.modelName} ready. Send your first message! ); @@ -230,7 +236,20 @@ function DownloadingRow({ : `${status.percent}%`; return ( - {label} + {/* Crossfade between the two labels so the swap is a soft dissolve, not + a hard cut. mode="wait" fades the old out before the new fades in. */} + + + {label} + + {trailing} { />, ); expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); - act(() => vi.advanceTimersByTime(4000)); + act(() => vi.advanceTimersByTime(7000)); expect( - screen.getByText('Close anytime, runs in the background'), + screen.getByText('You can close and come back anytime'), ).toBeInTheDocument(); - act(() => vi.advanceTimersByTime(4000)); + act(() => vi.advanceTimersByTime(7000)); expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); }); @@ -134,10 +134,14 @@ describe('DownloadStatusStrip', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - it('shows a ready state that invites the first message', () => { - render(); + it('names the model and invites the first message when ready', () => { + render( + , + ); expect( - screen.getByText('Model ready. Send your first message'), + screen.getByText('Qwen3.5 9B ready. Send your first message!'), ).toBeInTheDocument(); }); diff --git a/src/view/__tests__/AskBarView.test.tsx b/src/view/__tests__/AskBarView.test.tsx index 6612f47a..41e9a1aa 100644 --- a/src/view/__tests__/AskBarView.test.tsx +++ b/src/view/__tests__/AskBarView.test.tsx @@ -234,7 +234,7 @@ describe('AskBarView', () => { onSubmit={vi.fn()} onCancel={vi.fn()} inputRef={makeRef()} - downloadStatus={{ kind: 'ready' }} + downloadStatus={{ kind: 'ready', modelName: 'Qwen3.5 9B' }} />, ); expect( From 5e9d7bb543f05c3a95b26f05c8b255eb0a2077d4 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 13:20:13 -0500 Subject: [PATCH 38/51] fix: restart a relaunched download fresh instead of into a guaranteed verify error Signed-off-by: Logan Nguyen --- src/contexts/DownloadContext.tsx | 20 ++++++--- .../__tests__/DownloadContext.test.tsx | 44 +++++++++++++++++-- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index b7647e5f..d28cf69d 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -90,7 +90,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { const [pauseRequested, setPauseRequested] = useState(false); const [pausedBytes, setPausedBytes] = useState(0); - const { start, resume, cancel, combinedBytes } = download; + const { start, resume, cancel, discard, combinedBytes } = download; const downloadPhase = download.state.phase; // A pause is only *committed* once the cancel has fully landed (machine back @@ -127,7 +127,7 @@ export function DownloadProvider({ children }: { children: ReactNode }) { // On launch, recover an interrupted built-in download: if the engine is the // active provider and a starter has a partial on disk but none is installed, - // resume it in the background so the ambient strip is the recovery surface. + // restart it in the background so the ambient strip is the recovery surface. // The relaunch no longer bounces the user back to the picker, so this is what // keeps them from being stranded with no model. Fires once: the ref guards // against the StrictMode double-invoke and any later provider re-render. @@ -139,16 +139,24 @@ export function DownloadProvider({ children }: { children: ReactNode }) { if (activeProviderKind !== 'builtin') return; void (async () => { // The model_check picker owns the resume decision (its own Resume / - // Discard choice), so only auto-resume once the user is past it: the - // intro tour or the ask bar. + // Discard choice), so only act once the user is past it: the intro tour + // or the ask bar. const stage = await invoke('onboarding_stage'); if (stage !== 'intro' && stage !== 'complete') return; const options = await invoke('get_starter_options'); const partial = options.find((o) => o.partial_bytes !== null); if (options.some((o) => o.installed) || partial === undefined) return; - resumeDownload(partial.starter.tier, partial, partial.partial_bytes!); + // A cold-restart resume re-hashes the on-disk prefix and appends a Range + // body, but that path fails verification against the live CDN every time, + // so it would only ever re-download after a scary "did not verify" error. + // Discard the partial(s) and download fresh instead: same bytes, no error. + await discard(partial.starter.sha256); + if (partial.starter.mmproj_sha256 !== null) { + await discard(partial.starter.mmproj_sha256); + } + beginDownload(partial.starter.tier, partial); })(); - }, [activeProviderKind, resumeDownload]); + }, [activeProviderKind, discard, beginDownload]); const pauseDownload = useCallback(() => { // Remember how far we got so the paused strip can show the percent, then diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx index 39972e72..d4a91ee8 100644 --- a/src/contexts/__tests__/DownloadContext.test.tsx +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -235,9 +235,18 @@ describe('DownloadContext', () => { }); describe('launch auto-resume', () => { - it('resumes an interrupted partial past the picker (intro) with no installed model', async () => { + /** Flush the multi-await auto-resume IIFE (stage, options, discards). */ + async function flushLaunch() { + for (let i = 0; i < 6; i++) { + await act(async () => { + await Promise.resolve(); + }); + } + } + + it('discards an interrupted partial and downloads fresh past the picker', async () => { const partial: StarterOption = { - ...option({ tier: 'fast', size_bytes: 4_000_000_000, mmproj_bytes: 0 }), + ...option({ tier: 'fast' }), partial_bytes: 3_000_000_000, }; mockLaunch('intro', [partial]); @@ -245,11 +254,19 @@ describe('DownloadContext', () => { const { result } = renderHook(() => useDownloadCtx(), { wrapper: builtinWrapper, }); - await act(async () => {}); + await flushLaunch(); expect(invokeCount('get_starter_options')).toBe(1); + // The unreliable cold-resume is skipped: both blobs' partials are + // discarded and a fresh download starts (no resume seed). + expect(invoke).toHaveBeenCalledWith('discard_partial_download', { + sha256: 'sha', + }); + expect(invoke).toHaveBeenCalledWith('discard_partial_download', { + sha256: 'mmsha', + }); expect(result.current.downloadingTier).toBe('fast'); - expect(result.current.resumeSeedBytes).toBe(3_000_000_000); + expect(result.current.resumeSeedBytes).toBeNull(); expect(result.current.state).toEqual({ phase: 'downloading' }); expect(invoke).toHaveBeenCalledWith('download_starter', { tier: 'fast', @@ -257,6 +274,25 @@ describe('DownloadContext', () => { }); }); + it('discards only the weights partial for a text-only starter', async () => { + const partial: StarterOption = { + ...option({ mmproj_file: null, mmproj_sha256: null, mmproj_bytes: 0 }), + partial_bytes: 3_000_000_000, + }; + mockLaunch('intro', [partial]); + + renderHook(() => useDownloadCtx(), { wrapper: builtinWrapper }); + await flushLaunch(); + + expect(invoke).toHaveBeenCalledWith('discard_partial_download', { + sha256: 'sha', + }); + expect(invoke).not.toHaveBeenCalledWith('discard_partial_download', { + sha256: 'mmsha', + }); + expect(invokeCount('download_starter')).toBe(1); + }); + it('does not resume at the model_check picker (it owns the resume choice)', async () => { const partial: StarterOption = { ...option(), From 481b3b5d2a0c2644efde366078d4d0d3bcb8ea6a Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 13:20:13 -0500 Subject: [PATCH 39/51] feat: fit the permissions window to its card like the intro Signed-off-by: Logan Nguyen --- src/view/onboarding/PermissionsStep.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/view/onboarding/PermissionsStep.tsx b/src/view/onboarding/PermissionsStep.tsx index e5dab124..763d5f9a 100644 --- a/src/view/onboarding/PermissionsStep.tsx +++ b/src/view/onboarding/PermissionsStep.tsx @@ -3,6 +3,7 @@ import type React from 'react'; import { useState, useEffect, useRef, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import thukiLogo from '../../../src-tauri/icons/128x128.png'; +import { useFitOnboardingWindow } from '../../hooks/useFitOnboardingWindow'; import { StepCard, Badge } from './_shared'; /** How often to poll for permission grants after the user requests them. */ @@ -152,6 +153,12 @@ const Spinner = () => ( * against the macOS desktop. */ export function PermissionsStep() { + // Match the transparent window to the card so its empty area never blocks + // clicks to the apps behind Thuki (the card has a fixed layout, so the fit + // runs once on mount). + const cardRef = useRef(null); + useFitOnboardingWindow(cardRef, null); + const [accessibilityStatus, setAccessibilityStatus] = useState('pending'); const [screenRecordingStatus, setScreenRecordingStatus] = @@ -320,6 +327,7 @@ export function PermissionsStep() { }} > Date: Thu, 18 Jun 2026 13:52:54 -0500 Subject: [PATCH 40/51] fix: skip re-downloading an already-installed file when resuming a multi-file download Signed-off-by: Logan Nguyen --- src-tauri/src/models/download.rs | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src-tauri/src/models/download.rs b/src-tauri/src/models/download.rs index 243aaf51..4757c635 100644 --- a/src-tauri/src/models/download.rs +++ b/src-tauri/src/models/download.rs @@ -164,6 +164,22 @@ async fn download_one( cancel: &CancellationToken, emit: &impl Fn(DownloadEvent), ) -> Result { + // Already installed as a verified blob: the first file of a multi-file + // download that finished before a later file was interrupted. Skip it so a + // resume does not re-download a completed file; emit Started(full) + FileDone + // so the combined bar still counts its bytes. + if store.blob_path(&spec.sha256).exists() { + emit(DownloadEvent::Started { + file: spec.file.clone(), + total_bytes: spec.total_bytes, + resumed_from: spec.total_bytes, + }); + emit(DownloadEvent::FileDone { + file: spec.file.clone(), + }); + return Ok(FileOutcome::Done); + } + let resumed_from = store.existing_partial_len(&spec.sha256).unwrap_or(0); emit(DownloadEvent::Started { file: spec.file.clone(), @@ -590,6 +606,44 @@ mod tests { assert_eq!(std::fs::read(store.blob_path(&sha)).unwrap(), body); } + #[tokio::test] + async fn skips_an_already_installed_blob_without_downloading() { + // A multi-file download whose first file already installed must not + // re-download it on a resume: the blob is skipped (no HTTP request) and + // its bytes are still counted via Started(full) + FileDone. + let body = body_of(8192); + let sha = sha256_of(&body); + let (_dir, store) = make_store(); + std::fs::create_dir_all(store.blob_path(&sha).parent().unwrap()).unwrap(); + std::fs::write(store.blob_path(&sha), &body).unwrap(); + // An unroutable URL: if the code tried to download, this would error. + let spec = spec_for("http://127.0.0.1:1/nope".to_string(), "w.gguf", &body); + let (events, emit) = collector(); + + let result = run_download( + &[spec], + &store, + &reqwest::Client::new(), + CancellationToken::new(), + emit, + ) + .await; + + assert_eq!(result, Ok(())); + let evs = events.lock().unwrap(); + assert_eq!( + evs[0], + DownloadEvent::Started { + file: "w.gguf".to_string(), + total_bytes: 8192, + resumed_from: 8192, + } + ); + assert!(evs.contains(&DownloadEvent::FileDone { + file: "w.gguf".to_string() + })); + } + #[tokio::test] async fn resume_emits_verifying_before_rehash() { // On resume the existing prefix is re-hashed before the remaining bytes From 39da2f89be1a26ba8da344e044e823048d00ce2a Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 13:52:54 -0500 Subject: [PATCH 41/51] feat: fit the model-picker window to its card Signed-off-by: Logan Nguyen --- src/view/onboarding/ModelCheckStep.tsx | 192 +++++++++++++------------ 1 file changed, 101 insertions(+), 91 deletions(-) diff --git a/src/view/onboarding/ModelCheckStep.tsx b/src/view/onboarding/ModelCheckStep.tsx index 1e448b6d..3e014073 100644 --- a/src/view/onboarding/ModelCheckStep.tsx +++ b/src/view/onboarding/ModelCheckStep.tsx @@ -18,9 +18,10 @@ import { AnimatePresence, motion } from 'framer-motion'; import type React from 'react'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, forwardRef } from 'react'; import { invoke } from '@tauri-apps/api/core'; import thukiLogo from '../../../src-tauri/icons/128x128.png'; +import { useFitOnboardingWindow } from '../../hooks/useFitOnboardingWindow'; import { useConfig } from '../../contexts/ConfigContext'; import { useDownloadCtx } from '../../contexts/DownloadContext'; import { FIT_COPY, useStarterOptions } from '../../components/StarterPicker'; @@ -282,8 +283,14 @@ function BuiltinModelCheck({ onUseOllama }: { onUseOllama: () => void }) { onUseOllama(); }, [state.phase, cancel, onUseOllama]); + // Match the transparent window to the picker card so its empty area never + // blocks background clicks. Re-fit when the card height changes: options + // loading in, or a download phase that adds rows (progress, resume, failed). + const cardRef = useRef(null); + useFitOnboardingWindow(cardRef, `${options === null}:${state.phase}`); + return ( - + {options === null ? null : (
void }) { * (logo, title, privacy footer) so onboarding stays visually coherent; the * legacy markup itself is left untouched inside `OllamaModelCheck`. */ -function BuiltinShell({ children }: { children: React.ReactNode }) { - return ( -
- ( + function BuiltinShell({ children }, ref) { + return ( +
-
- -
- Thuki -
-

- Set up your local AI -

-

- Pick a starter brain for Thuki. Downloads once, then runs fully - offline. -

+
+ Thuki +
- {children} +

+ Set up your local AI +

+

+ Pick a starter brain for Thuki. Downloads once, then runs fully + offline. +

-

- Private by default · All inference runs on your machine -

- -
- ); -} + {children} + +

+ Private by default · All inference runs on your machine +

+ +
+ ); + }, +); // ─── Legacy Ollama flow (kept verbatim) ────────────────────────────────────── From 220d71eee38004cc2acb61c31d346eb5c9769e9b Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 13:52:54 -0500 Subject: [PATCH 42/51] feat: alternate the download label on the ask bar only, slower, with a no-quit hint Signed-off-by: Logan Nguyen --- src/components/DownloadStatusStrip.tsx | 33 ++++++++++++------- .../__tests__/DownloadStatusStrip.test.tsx | 30 ++++++++++++++--- src/view/AskBarView.tsx | 4 ++- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/components/DownloadStatusStrip.tsx b/src/components/DownloadStatusStrip.tsx index c9607d0a..258244b3 100644 --- a/src/components/DownloadStatusStrip.tsx +++ b/src/components/DownloadStatusStrip.tsx @@ -36,13 +36,12 @@ export type DownloadStripStatus = * other. Slow on purpose: the strip is ambient, so the swap should be a calm * background rhythm, not something that pulls the eye. */ -const LABEL_ROTATE_MS = 7000; +const LABEL_ROTATE_MS = 12000; /** - * The reassurance half of the alternating label: the download keeps going while - * Thuki is closed (hidden), so the user can step away and come back. "Close" - * (not quit) is deliberate: quitting from the tray is what stops it. + * The reassurance half of the alternating label (ask bar only): closing Thuki + * keeps the download going, but quitting stops it. Short and succinct. */ -const BACKGROUND_HINT = 'You can close and come back anytime'; +const BACKGROUND_HINT = "Safe to close, just don't quit"; const ORANGE = 'rgb(255,141,92)'; const ORANGE_FILL = 'linear-gradient(90deg,#ffa06f,#d45a1e)'; @@ -141,8 +140,15 @@ function Shell({ export function DownloadStatusStrip({ status, + alternate = false, }: { status: DownloadStripStatus; + /** + * When true, the downloading label alternates with the "safe to close" hint. + * Used on the ask bar; the intro shows just the model name (the hint would + * read oddly on a full setup screen the user is looking at). + */ + alternate?: boolean; }) { if (status.kind === 'ready') { return ( @@ -210,26 +216,31 @@ export function DownloadStatusStrip({ ); } - return ; + return ; } /** - * The byte-moving downloading row. Its label alternates between the model name - * and the "runs in the background" reassurance so both fit the single line; the - * percent, ETA, and Pause stay fixed. + * The byte-moving downloading row. On the ask bar (`alternate`) its label + * crossfades between the model name and the "safe to close" reassurance so both + * fit the single line; on the intro it stays the model name. The percent, ETA, + * and Pause stay fixed. */ function DownloadingRow({ status, + alternate, }: { status: Extract; + alternate: boolean; }) { const [showHint, setShowHint] = useState(false); useEffect(() => { + if (!alternate) return; const id = setInterval(() => setShowHint((s) => !s), LABEL_ROTATE_MS); return () => clearInterval(id); - }, []); + }, [alternate]); - const label = showHint ? BACKGROUND_HINT : `Downloading ${status.modelName}`; + const label = + alternate && showHint ? BACKGROUND_HINT : `Downloading ${status.modelName}`; const trailing = status.etaSeconds !== null ? `${status.percent}% · ${formatEta(status.etaSeconds)} left` diff --git a/src/components/__tests__/DownloadStatusStrip.test.tsx b/src/components/__tests__/DownloadStatusStrip.test.tsx index e01af544..15c64eb8 100644 --- a/src/components/__tests__/DownloadStatusStrip.test.tsx +++ b/src/components/__tests__/DownloadStatusStrip.test.tsx @@ -23,10 +23,11 @@ describe('DownloadStatusStrip', () => { expect(screen.getByText('62% · 1m left')).toBeInTheDocument(); }); - it('alternates the label with the background hint every few seconds', () => { + it('alternates the label with the background hint when alternate is set', () => { vi.useFakeTimers(); render( { />, ); expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); - act(() => vi.advanceTimersByTime(7000)); + act(() => vi.advanceTimersByTime(12000)); expect( - screen.getByText('You can close and come back anytime'), + screen.getByText("Safe to close, just don't quit"), ).toBeInTheDocument(); - act(() => vi.advanceTimersByTime(7000)); + act(() => vi.advanceTimersByTime(12000)); expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); }); + it('does not alternate the label by default (intro)', () => { + vi.useFakeTimers(); + render( + , + ); + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(12000)); + expect(screen.getByText('Downloading Qwen3.5 9B')).toBeInTheDocument(); + expect( + screen.queryByText("Safe to close, just don't quit"), + ).not.toBeInTheDocument(); + }); + it('omits the ETA when it is not yet measurable', () => { render( )} - {downloadStatus ? : null} + {downloadStatus ? ( + + ) : null} {/* Command suggestion renders above the input row in the normal DOM flow. Being inside the morphing container means the ResizeObserver detects the added height and grows the native window upward to reveal From 36329db9111f662151f9006dec4331baa09a5d16 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 14:24:01 -0500 Subject: [PATCH 43/51] feat: warn before quitting while a model download is in flight Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 36 ++++++++++++++++++++++++++++++++++-- src-tauri/src/models/mod.rs | 16 ++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 499183a4..792c3da9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1738,8 +1738,40 @@ pub fn run() { show_update_window(app); } "quit" => { - app.state::().cancel(); - app.exit(0); + // Quitting (tray Quit or its Cmd+Q accelerator) tears + // down the download task, discarding the in-flight + // chunks. Warn first so the user can close (hide) the + // app instead and let the download finish in the + // background. Only when a download is actually running. + if models::download_in_flight( + app.state::().inner(), + ) { + use tauri_plugin_dialog::{ + DialogExt, MessageDialogButtons, MessageDialogKind, + }; + let handle = app.clone(); + app.dialog() + .message( + "Quitting stops the model download and you'll have to start it over.\n\nTo keep it downloading in the background, just close Thuki instead (double-tap Control to reopen).", + ) + .title("Quit while a model is downloading?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Quit Anyway".to_string(), + "Keep Downloading".to_string(), + )) + .show(move |quit_anyway| { + if quit_anyway { + handle + .state::() + .cancel(); + handle.exit(0); + } + }); + } else { + app.state::().cancel(); + app.exit(0); + } } _ => {} }) diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 33e3a777..33894b9f 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1137,6 +1137,12 @@ pub fn release_download(state: &DownloadState) { } } +/// True while a model download holds the slot. Read before quitting so the app +/// can warn that quitting discards the in-flight download. +pub fn download_in_flight(state: &DownloadState) -> bool { + state.0.lock().map(|guard| guard.is_some()).unwrap_or(false) +} + /// Cancels the in-flight download's token, if one is claimed. Does NOT clear /// the slot: the download task notices the cancellation, emits `Cancelled`, /// and releases the slot itself. @@ -3779,6 +3785,16 @@ mod tests { assert!(claim_download(&state).is_ok()); } + #[test] + fn download_in_flight_tracks_the_claim() { + let state = DownloadState::default(); + assert!(!download_in_flight(&state)); + let _token = claim_download(&state).unwrap(); + assert!(download_in_flight(&state)); + release_download(&state); + assert!(!download_in_flight(&state)); + } + #[test] fn cancel_active_download_cancels_claimed_token_and_tolerates_idle() { let state = DownloadState::default(); From 4faf7c535bb4741e72df4c542cc3eb6be7aaa631 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 14:31:47 -0500 Subject: [PATCH 44/51] fix: catch Cmd+Q via ExitRequested so the download quit warning actually appears Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 85 ++++++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 792c3da9..67194fc7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -249,6 +249,40 @@ fn set_onboarding_active_impl(active: bool) { ONBOARDING_ACTIVE.store(active, Ordering::SeqCst); } +/// Set once the user confirms a quit (or quits with no download in flight), so +/// the re-entrant `ExitRequested` that `app.exit` raises is allowed straight +/// through instead of re-prompting the download warning forever. +static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false); + +/// Shows the native "quit while a model is downloading" warning. "Quit Anyway" +/// records the confirmation and exits; "Keep Downloading" cancels the quit. +/// Non-blocking: the callback runs when the user answers. Reached from both the +/// tray Quit click and Cmd+Q (via `RunEvent::ExitRequested`). +#[cfg_attr(coverage_nightly, coverage(off))] +fn show_quit_dialog(app: &tauri::AppHandle) { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + let handle = app.clone(); + app.dialog() + .message( + "Quitting stops the model download and you'll have to start it over.\n\nTo keep it downloading in the background, just close Thuki instead (double-tap Control to reopen).", + ) + .title("Quit while a model is downloading?") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Quit Anyway".to_string(), + "Keep Downloading".to_string(), + )) + .show(move |quit_anyway| { + if quit_anyway { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + handle + .state::() + .cancel(); + handle.exit(0); + } + }); +} + /// Payload emitted to the frontend on every visibility transition. #[derive(Clone, serde::Serialize)] struct VisibilityPayload { @@ -1738,36 +1772,14 @@ pub fn run() { show_update_window(app); } "quit" => { - // Quitting (tray Quit or its Cmd+Q accelerator) tears - // down the download task, discarding the in-flight - // chunks. Warn first so the user can close (hide) the - // app instead and let the download finish in the - // background. Only when a download is actually running. - if models::download_in_flight( - app.state::().inner(), - ) { - use tauri_plugin_dialog::{ - DialogExt, MessageDialogButtons, MessageDialogKind, - }; - let handle = app.clone(); - app.dialog() - .message( - "Quitting stops the model download and you'll have to start it over.\n\nTo keep it downloading in the background, just close Thuki instead (double-tap Control to reopen).", - ) - .title("Quit while a model is downloading?") - .kind(MessageDialogKind::Warning) - .buttons(MessageDialogButtons::OkCancelCustom( - "Quit Anyway".to_string(), - "Keep Downloading".to_string(), - )) - .show(move |quit_anyway| { - if quit_anyway { - handle - .state::() - .cancel(); - handle.exit(0); - } - }); + // The tray Quit click. Cmd+Q does NOT reach here (a + // status-bar menu's key-equivalent is not a global + // shortcut); that path is handled in RunEvent:: + // ExitRequested. Both route through show_quit_dialog so + // an in-flight download is never torn down silently. + if models::download_in_flight(app.state::().inner()) + { + show_quit_dialog(app); } else { app.state::().cancel(); app.exit(0); @@ -2276,6 +2288,19 @@ pub fn run() { } } } + RunEvent::ExitRequested { api, .. } => { + // Cmd+Q (and any app.exit issued before the user has confirmed) + // lands here. If a model download is in flight, hold the exit + // and warn so the user can keep it running in the background. + if !QUIT_CONFIRMED.load(Ordering::SeqCst) + && models::download_in_flight( + app_handle.state::().inner(), + ) + { + api.prevent_exit(); + show_quit_dialog(app_handle); + } + } RunEvent::Exit => { // Kill the built-in engine sidecar and confirm its exit so // no orphan llama-server survives quit. The actor runs on From e2c971b361169707590f3e21b8c225169ca8e653 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 14:43:07 -0500 Subject: [PATCH 45/51] fix: replace the default macOS menu so Cmd+Q routes through the download quit warning Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 67194fc7..0d76b7f6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1603,6 +1603,47 @@ pub fn build_trace_inner( Arc::new(trace::RegistryRecorder::new(traces_root)) } +// ─── Menu helpers ──────────────────────────────────────────────────────────── + +/// Custom macOS application menu, replacing Tauri's default. The Quit item is a +/// custom one (id "quit", Cmd+Q) so quitting routes through `show_quit_dialog` +/// instead of the predefined hard-quit that ignores an in-flight download. The +/// Edit submenu is kept so the ask bar's copy / paste / select-all shortcuts +/// (which the replaced default menu provided) keep working. +#[cfg_attr(coverage_nightly, coverage(off))] +fn build_app_menu( + app: &tauri::AppHandle, +) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; + + let quit = MenuItem::with_id(app, "quit", "Quit Thuki", true, Some("Cmd+Q"))?; + let app_menu = Submenu::with_items( + app, + "Thuki", + true, + &[ + &PredefinedMenuItem::about(app, Some("About Thuki"), None)?, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?; + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + Menu::with_items(app, &[&app_menu, &edit_menu]) +} + // ─── Tray helpers ──────────────────────────────────────────────────────────── /// Builds the system-tray menu. When `update_version` is `Some`, a @@ -1719,6 +1760,20 @@ pub fn run() { builder .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) + // Replace Tauri's default macOS menu: its predefined Quit does a hard + // quit on Cmd+Q that bypasses our handlers. Our custom Quit fires this + // handler instead, so a download in flight gets the warning. + .menu(build_app_menu) + .on_menu_event(|app, event| { + if event.id.as_ref() == "quit" { + if models::download_in_flight(app.state::().inner()) { + show_quit_dialog(app); + } else { + app.state::().cancel(); + app.exit(0); + } + } + }) .setup(|app| { #[cfg(target_os = "macos")] app.set_activation_policy(ActivationPolicy::Accessory); From 6d9c0bec0faaf9c14e70ebbf0f1b7ed262aa803d Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 14:57:29 -0500 Subject: [PATCH 46/51] fix: show a single quit warning and warn while a download is paused Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 83 +++++++++++++------ src/contexts/DownloadContext.tsx | 8 ++ .../__tests__/DownloadContext.test.tsx | 5 ++ 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d76b7f6..97814334 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -254,13 +254,55 @@ fn set_onboarding_active_impl(active: bool) { /// through instead of re-prompting the download warning forever. static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false); +/// True while the quit warning dialog is on screen. Cmd+Q reaches the warning +/// twice (the app-menu Quit event AND `RunEvent::ExitRequested`); this guard +/// keeps it to a single dialog instead of two stacked ones. +static QUIT_DIALOG_OPEN: AtomicBool = AtomicBool::new(false); + +/// True while a model download is paused, set by the frontend via +/// `set_download_paused`. A paused download still has work left (the partial is +/// discarded on the next launch), so the quit warning must cover it too, not +/// only an actively-streaming download. +static DOWNLOAD_PAUSED: AtomicBool = AtomicBool::new(false); + +/// Frontend hook so the quit warning fires for a paused download, not only an +/// actively-streaming one. The pause cancels the backend task, so the slot is +/// free and only the frontend knows a download is paused. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +fn set_download_paused(paused: bool) { + DOWNLOAD_PAUSED.store(paused, Ordering::SeqCst); +} + +/// Whether quitting now would discard an in-progress model download: one is +/// actively streaming, or one is paused. +fn should_warn_on_quit(app: &tauri::AppHandle) -> bool { + models::download_in_flight(app.state::().inner()) + || DOWNLOAD_PAUSED.load(Ordering::SeqCst) +} + +/// Handles a quit request from the app menu or the tray: warn when a download +/// would be lost, otherwise quit immediately. +#[cfg_attr(coverage_nightly, coverage(off))] +fn request_quit(app: &tauri::AppHandle) { + if should_warn_on_quit(app) { + show_quit_dialog(app); + } else { + app.state::().cancel(); + app.exit(0); + } +} + /// Shows the native "quit while a model is downloading" warning. "Quit Anyway" /// records the confirmation and exits; "Keep Downloading" cancels the quit. -/// Non-blocking: the callback runs when the user answers. Reached from both the -/// tray Quit click and Cmd+Q (via `RunEvent::ExitRequested`). +/// Non-blocking, and deduplicated via `QUIT_DIALOG_OPEN` so the two quit paths +/// that both fire on Cmd+Q show a single dialog. #[cfg_attr(coverage_nightly, coverage(off))] fn show_quit_dialog(app: &tauri::AppHandle) { use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + if QUIT_DIALOG_OPEN.swap(true, Ordering::SeqCst) { + return; + } let handle = app.clone(); app.dialog() .message( @@ -273,6 +315,7 @@ fn show_quit_dialog(app: &tauri::AppHandle) { "Keep Downloading".to_string(), )) .show(move |quit_anyway| { + QUIT_DIALOG_OPEN.store(false, Ordering::SeqCst); if quit_anyway { QUIT_CONFIRMED.store(true, Ordering::SeqCst); handle @@ -1766,12 +1809,7 @@ pub fn run() { .menu(build_app_menu) .on_menu_event(|app, event| { if event.id.as_ref() == "quit" { - if models::download_in_flight(app.state::().inner()) { - show_quit_dialog(app); - } else { - app.state::().cancel(); - app.exit(0); - } + request_quit(app); } }) .setup(|app| { @@ -1827,18 +1865,10 @@ pub fn run() { show_update_window(app); } "quit" => { - // The tray Quit click. Cmd+Q does NOT reach here (a - // status-bar menu's key-equivalent is not a global - // shortcut); that path is handled in RunEvent:: - // ExitRequested. Both route through show_quit_dialog so - // an in-flight download is never torn down silently. - if models::download_in_flight(app.state::().inner()) - { - show_quit_dialog(app); - } else { - app.state::().cancel(); - app.exit(0); - } + // Tray Quit click. Cmd+Q reaches the app menu + Exit + // Requested instead, all routed through request_quit so + // an in-progress download is never torn down silently. + request_quit(app); } _ => {} }) @@ -2228,6 +2258,8 @@ pub fn run() { #[cfg(not(coverage))] models::discard_partial_download, #[cfg(not(coverage))] + set_download_paused, + #[cfg(not(coverage))] models::list_installed_models, #[cfg(not(coverage))] models::delete_installed_model, @@ -2345,13 +2377,10 @@ pub fn run() { } RunEvent::ExitRequested { api, .. } => { // Cmd+Q (and any app.exit issued before the user has confirmed) - // lands here. If a model download is in flight, hold the exit - // and warn so the user can keep it running in the background. - if !QUIT_CONFIRMED.load(Ordering::SeqCst) - && models::download_in_flight( - app_handle.state::().inner(), - ) - { + // lands here. If a download would be lost, hold the exit and + // warn so the user can keep it running in the background. The + // dialog itself is deduplicated against the app-menu path. + if !QUIT_CONFIRMED.load(Ordering::SeqCst) && should_warn_on_quit(app_handle) { api.prevent_exit(); show_quit_dialog(app_handle); } diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index d28cf69d..74625ee5 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -103,6 +103,14 @@ export function DownloadProvider({ children }: { children: ReactNode }) { // down. The strip shows "Pausing…" here so the Pause click is never silent. const isPausing = pauseRequested && isDownloadInFlight(downloadPhase); + // A pause cancels the backend download task, so the slot is free and only the + // frontend knows a download is paused. Report it so the quit warning fires + // for a paused (or pausing) download too, not only an actively-streaming one. + const pausedForQuitWarning = isPaused || isPausing; + useEffect(() => { + void invoke('set_download_paused', { paused: pausedForQuitWarning }); + }, [pausedForQuitWarning]); + const beginDownload = useCallback( (tier: StarterTier, option: StarterOption) => { setResumeSeedBytes(null); diff --git a/src/contexts/__tests__/DownloadContext.test.tsx b/src/contexts/__tests__/DownloadContext.test.tsx index d4a91ee8..8f3fe83d 100644 --- a/src/contexts/__tests__/DownloadContext.test.tsx +++ b/src/contexts/__tests__/DownloadContext.test.tsx @@ -193,6 +193,11 @@ describe('DownloadContext', () => { act(() => channel().simulateMessage({ type: 'Cancelled' })); expect(result.current.isPaused).toBe(true); expect(result.current.isPausing).toBe(false); + + // The paused state is reported to the backend so Cmd+Q warns while paused. + expect(invoke).toHaveBeenCalledWith('set_download_paused', { + paused: true, + }); }); it('pauseDownload defaults to zero bytes before the first event arrives', async () => { From 032609204abdaf8c1cabadcbf385bd857179885d Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 15:20:45 -0500 Subject: [PATCH 47/51] fix: re-fit the onboarding window on content changes and give the picker a Size row Signed-off-by: Logan Nguyen --- src/components/StarterMatrix.tsx | 34 ++++++++++++----------------- src/hooks/useFitOnboardingWindow.ts | 30 +++++++++++++++---------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/components/StarterMatrix.tsx b/src/components/StarterMatrix.tsx index 015947e5..e680256b 100644 --- a/src/components/StarterMatrix.tsx +++ b/src/components/StarterMatrix.tsx @@ -290,6 +290,7 @@ function LabelColumn() { return (
+ {cell('Size')} {cell('Speed')} {cell('Quality')} {cell('Vision')} @@ -357,7 +358,8 @@ function TierColumn({ : 'transparent', }} > - {/* Header: tier eyebrow, then name + size on one line */} + {/* Header: tier eyebrow, then the model name (size moved to its own row + so it never truncates next to a long name). */}
- - {starter.display_name} - - - {gb(totalBytes(option))} GB - + {starter.display_name}
+ + + {gb(totalBytes(option))} GB + + + diff --git a/src/hooks/useFitOnboardingWindow.ts b/src/hooks/useFitOnboardingWindow.ts index bb08227e..33399050 100644 --- a/src/hooks/useFitOnboardingWindow.ts +++ b/src/hooks/useFitOnboardingWindow.ts @@ -15,10 +15,12 @@ import { LogicalSize } from '@tauri-apps/api/dpi'; * * Measurement uses `offsetWidth`/`offsetHeight` (the layout border box), which * ignores the card's entrance transform, and runs in a layout effect so the - * resize happens before paint and the strip never flashes clipped. + * resize happens before paint and the card never flashes clipped. * - * `changeKey` is any value that changes when the card height changes (the - * ambient download status). The fit re-runs whenever it changes identity. + * A `ResizeObserver` re-fits on ANY later content change (async data loading + * in, a conditional line appearing), so the window can never end up shorter + * than the card and clip its bottom. `changeKey` forces an immediate re-fit + * for the known triggers without waiting for the observer's next callback. */ export function useFitOnboardingWindow( ref: RefObject, @@ -27,13 +29,19 @@ export function useFitOnboardingWindow( useLayoutEffect(() => { const node = ref.current; if (!node) return; - const width = node.offsetWidth; - const height = node.offsetHeight; - if (width === 0 || height === 0) return; - void (async () => { - const win = getCurrentWindow(); - await win.setSize(new LogicalSize(width, height)); - await win.center(); - })(); + const fit = () => { + const width = node.offsetWidth; + const height = node.offsetHeight; + if (width === 0 || height === 0) return; + void (async () => { + const win = getCurrentWindow(); + await win.setSize(new LogicalSize(width, height)); + await win.center(); + })(); + }; + fit(); + const observer = new ResizeObserver(fit); + observer.observe(node); + return () => observer.disconnect(); }, [ref, changeKey]); } From 4c0392baaeccb848b98eb8127f5a6004c399ac01 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 15:20:45 -0500 Subject: [PATCH 48/51] feat: make Keep Downloading the default button in the quit warning Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 97814334..df6459bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -310,13 +310,16 @@ fn show_quit_dialog(app: &tauri::AppHandle) { ) .title("Quit while a model is downloading?") .kind(MessageDialogKind::Warning) + // "Keep Downloading" is the primary/highlighted button (the default on + // Enter): the safe choice for a destructive action. "Quit Anyway" is the + // secondary. The callback's bool is true for the primary button. .buttons(MessageDialogButtons::OkCancelCustom( - "Quit Anyway".to_string(), "Keep Downloading".to_string(), + "Quit Anyway".to_string(), )) - .show(move |quit_anyway| { + .show(move |keep_downloading| { QUIT_DIALOG_OPEN.store(false, Ordering::SeqCst); - if quit_anyway { + if !keep_downloading { QUIT_CONFIRMED.store(true, Ordering::SeqCst); handle .state::() From 0376be7e2f18d3529c8d7d8164ed291da6d0e89f Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 15:46:25 -0500 Subject: [PATCH 49/51] fix: override undici to ^7.28.0 to clear the security audit gate Signed-off-by: Logan Nguyen --- bun.lock | 3 ++- package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 170b48b6..22d49b60 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,7 @@ "overrides": { "lodash-es": ">=4.18.0", "picomatch": ">=4.0.4", + "undici": "^7.28.0", "vite": "^8.0.16", }, "packages": { @@ -1186,7 +1187,7 @@ "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - "undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index 0c243fb3..f7e14964 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "overrides": { "picomatch": ">=4.0.4", "lodash-es": ">=4.18.0", - "vite": "^8.0.16" + "vite": "^8.0.16", + "undici": "^7.28.0" }, "devDependencies": { "@eslint-react/eslint-plugin": "5.9.0", From d60045a41d1694ef57044146ab2dbe8113bac749 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 16:01:26 -0500 Subject: [PATCH 50/51] fix: show friendly model display names in the titlebar pill and attribution chip Signed-off-by: Logan Nguyen --- src/App.tsx | 1 + src/components/ChatBubble.tsx | 13 +++++++++++- src/components/WindowControls.tsx | 11 +++++++++- src/components/__tests__/ChatBubble.test.tsx | 21 +++++++++++++++++++ .../__tests__/WindowControls.test.tsx | 19 +++++++++++++++++ src/view/ConversationView.tsx | 10 +++++++++ 6 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2ed9d3be..85d61279 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3531,6 +3531,7 @@ function App() { onReplace={performReplace} searchStage={searchStage} activeModel={activeModel} + modelDisplayNames={modelDisplayNames} onModelPickerToggle={ ollamaReachable ? handleModelPickerToggle diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx index 11749565..86ae3b61 100644 --- a/src/components/ChatBubble.tsx +++ b/src/components/ChatBubble.tsx @@ -267,6 +267,14 @@ interface ChatBubbleProps { isSearching?: boolean; /** When set on an assistant message, renders a chip-style attribution badge beside the CopyButton so the user sees which model produced this response. */ modelName?: string; + /** + * Friendly display name per model id. When `modelName` has an entry + * (built-in models, whose ids are the raw "repo:file.gguf" slug), the + * attribution chip renders the friendly name; ids without an entry render + * verbatim (already clean for Ollama / OpenAI). Keeps the chip consistent + * with the model picker and the titlebar pill. + */ + displayNames?: Record; } /** @@ -318,6 +326,7 @@ export function ChatBubble({ searchTraces, isSearching = false, modelName, + displayNames, }: ChatBubbleProps) { const isUser = role === 'user'; const [sourcesOpen, setSourcesOpen] = useState(false); @@ -602,7 +611,9 @@ export function ChatBubble({ {ATTRIB_CHIP_ICON} - {modelName} + + {displayNames?.[modelName] ?? modelName} + )}
diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index a8d6a6a0..a45f443a 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -156,6 +156,14 @@ interface WindowControlsProps { * model is selected, so it must be reachable even with a null active. */ activeModel?: string | null; + /** + * Friendly display name per model id. When the active model id has an entry + * (built-in models, whose ids are the raw "repo:file.gguf" slug), the pill + * renders the friendly name instead; ids without an entry render verbatim + * (already clean for Ollama / OpenAI). Keeps the pill label consistent with + * the model picker. + */ + displayNames?: Record; /** * Called when the user clicks the active-model pill to open/close the picker. * Omit to hide the pill entirely. When provided the pill always renders, @@ -192,6 +200,7 @@ export const WindowControls = memo(function WindowControls({ onHistoryOpen, onNewConversation, activeModel, + displayNames, onModelPickerToggle, isModelPickerOpen = false, onMinimize, @@ -323,7 +332,7 @@ export const WindowControls = memo(function WindowControls({ }`} > {activeModel != null && activeModel.length > 0 - ? activeModel + ? (displayNames?.[activeModel] ?? activeModel) : 'Pick a model'} diff --git a/src/components/__tests__/ChatBubble.test.tsx b/src/components/__tests__/ChatBubble.test.tsx index 555dc306..d68d19fe 100644 --- a/src/components/__tests__/ChatBubble.test.tsx +++ b/src/components/__tests__/ChatBubble.test.tsx @@ -1152,6 +1152,27 @@ describe('ChatBubble', () => { expect(chip).toHaveTextContent('gemma4:e2b'); }); + it('renders the friendly display name in the chip when the model id has one', () => { + // Built-in model ids are raw "repo:file.gguf" slugs; the chip must show + // the elegant label, matching the model picker and titlebar pill. + render( + , + ); + const chip = screen.getByTestId('model-attribution'); + expect(chip).toHaveTextContent('Qwen3.5 9B'); + expect(chip).not.toHaveTextContent( + 'unsloth/Qwen3.5:Qwen3.5-9B-Q4_K_M.gguf', + ); + }); + it('does not render the attribution chip when modelName is absent', () => { render(); expect(screen.queryByTestId('model-attribution')).toBeNull(); diff --git a/src/components/__tests__/WindowControls.test.tsx b/src/components/__tests__/WindowControls.test.tsx index d94745b6..062917be 100644 --- a/src/components/__tests__/WindowControls.test.tsx +++ b/src/components/__tests__/WindowControls.test.tsx @@ -104,6 +104,25 @@ describe('WindowControls', () => { expect(screen.getByText('gemma4:e2b')).toBeInTheDocument(); }); + it('renders the friendly display name when the active model id has one', () => { + // Built-in model ids are raw "repo:file.gguf" slugs; the pill must show + // the elegant label, matching the model picker. + render( + , + ); + expect(screen.getByText('Qwen3.5 9B')).toBeInTheDocument(); + expect( + screen.queryByText('unsloth/Qwen3.5:Qwen3.5-9B-Q4_K_M.gguf'), + ).toBeNull(); + }); + it('renders the picker chip with a "Pick a model" placeholder when activeModel is null', () => { // The chip is the recovery affordance for the no-model state, so it // must stay visible (and clickable) even when activeModel is null. diff --git a/src/view/ConversationView.tsx b/src/view/ConversationView.tsx index ff85c0e7..213c2a4c 100644 --- a/src/view/ConversationView.tsx +++ b/src/view/ConversationView.tsx @@ -82,6 +82,13 @@ interface ConversationViewProps { /** Currently active model slug forwarded to the WindowControls pill trigger. * `null` keeps the chip visible with a "Pick a model" placeholder. */ activeModel?: string | null; + /** + * Friendly display name per model id, forwarded to the titlebar pill and the + * per-message attribution chips so built-in model ids render their elegant + * label (e.g. "Qwen3.5 9B") instead of the raw "repo:file.gguf" slug, exactly + * as the model picker does. Ids without an entry render verbatim. + */ + modelDisplayNames?: Record; /** Toggles the model picker panel; forwarded to WindowControls. */ onModelPickerToggle?: () => void; /** Whether the model picker panel is open; drives aria-expanded on the pill. */ @@ -124,6 +131,7 @@ export function ConversationView({ onReplace, searchStage = null, activeModel, + modelDisplayNames, onModelPickerToggle, isModelPickerOpen, onMinimize, @@ -230,6 +238,7 @@ export function ConversationView({ onNewConversation={onNewConversation} onHistoryOpen={onHistoryOpen} activeModel={activeModel} + displayNames={modelDisplayNames} onModelPickerToggle={onModelPickerToggle} isModelPickerOpen={isModelPickerOpen} onMinimize={onMinimize} @@ -292,6 +301,7 @@ export function ConversationView({ sandboxUnavailable={msg.sandboxUnavailable} searchTraces={msg.searchTraces} modelName={msg.modelName} + displayNames={modelDisplayNames} isSearching={ isGenerating && msg.fromSearch === true && From d98e720f4c5ceae444b1ebbed9f29610ba31410a Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 16:22:55 -0500 Subject: [PATCH 51/51] fix: ignore out-of-order openai model-list refreshes in Settings Signed-off-by: Logan Nguyen --- src/settings/tabs/ProviderCards.test.tsx | 94 ++++++++++++++++++++++++ src/settings/tabs/ProviderCards.tsx | 7 ++ 2 files changed, 101 insertions(+) diff --git a/src/settings/tabs/ProviderCards.test.tsx b/src/settings/tabs/ProviderCards.test.tsx index 31c1d164..b8127bf4 100644 --- a/src/settings/tabs/ProviderCards.test.tsx +++ b/src/settings/tabs/ProviderCards.test.tsx @@ -249,6 +249,28 @@ async function flush() { }); } +/** + * A queue of externally-settled promises, used to control the resolution + * order of overlapping async responses (e.g. two in-flight model-list calls). + */ +function deferredQueue() { + const items: Array<{ + resolve: (value: T) => void; + reject: (reason: unknown) => void; + }> = []; + const next = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + items.push({ resolve, reject }); + return promise; + }; + return { items, next }; +} + beforeEach(() => { invokeMock.mockReset(); lastChannel = null; @@ -1057,6 +1079,78 @@ describe('OpenAiProviderCard', () => { expect(screen.queryByText('old-model')).not.toBeInTheDocument(); }); + it('ignores a stale model-list response that resolves after a newer one', async () => { + const lists = deferredQueue(); + mockCommands({ + list_openai_models: () => lists.next(), + has_provider_api_key: false, + update_provider_field: configWith({ + ...OPENAI_PROVIDER, + base_url: 'http://127.0.0.1:9999', + }), + }); + render(); + await flush(); // mount fires the first refresh (lists.items[0]), still pending + + const url = screen.getByLabelText('OpenAI-compatible base URL'); + fireEvent.focus(url); + fireEvent.change(url, { target: { value: 'http://127.0.0.1:9999' } }); + fireEvent.blur(url); + // The committed base URL lifts a new config, re-running the effect and + // firing a second refresh (lists.items[1]) while the first is in flight. + await waitFor(() => expect(lists.items.length).toBe(2)); + + // Newer refresh settles first and wins. + await act(async () => { + lists.items[1].resolve(['new-model']); + await Promise.resolve(); + }); + expect(screen.getByText('new-model')).toBeInTheDocument(); + + // Stale earlier refresh settles late and must not overwrite the newer one. + await act(async () => { + lists.items[0].resolve(['old-model']); + await Promise.resolve(); + }); + expect(screen.queryByText('old-model')).not.toBeInTheDocument(); + expect(screen.getByText('new-model')).toBeInTheDocument(); + }); + + it('ignores a stale model-list rejection that settles after a newer success', async () => { + const lists = deferredQueue(); + mockCommands({ + list_openai_models: () => lists.next(), + has_provider_api_key: false, + update_provider_field: configWith({ + ...OPENAI_PROVIDER, + base_url: 'http://127.0.0.1:9999', + }), + }); + render(); + await flush(); + + const url = screen.getByLabelText('OpenAI-compatible base URL'); + fireEvent.focus(url); + fireEvent.change(url, { target: { value: 'http://127.0.0.1:9999' } }); + fireEvent.blur(url); + await waitFor(() => expect(lists.items.length).toBe(2)); + + await act(async () => { + lists.items[1].resolve(['new-model']); + await Promise.resolve(); + }); + expect(screen.getByText('new-model')).toBeInTheDocument(); + + // A late rejection from the superseded refresh must not surface an error + // or clear the newer model list. + await act(async () => { + lists.items[0].reject('late failure'); + await Promise.resolve(); + }); + expect(screen.queryByText('Couldn’t list models')).not.toBeInTheDocument(); + expect(screen.getByText('new-model')).toBeInTheDocument(); + }); + it('reverts the base URL when the commit fails; unchanged URL never commits', async () => { mockCommands({ list_openai_models: [], diff --git a/src/settings/tabs/ProviderCards.tsx b/src/settings/tabs/ProviderCards.tsx index 12693e1e..92150397 100644 --- a/src/settings/tabs/ProviderCards.tsx +++ b/src/settings/tabs/ProviderCards.tsx @@ -424,12 +424,19 @@ export function OpenAiProviderCard({ if (!baseUrlFocusedRef.current) setBaseUrl(provider.base_url); } + // Monotonic token guarding against out-of-order refreshes: a base URL or + // key change can leave an earlier `list_openai_models` call in flight, so a + // slow earlier response must not overwrite a newer one's result. + const refreshSeqRef = useRef(0); const refreshModels = useCallback(async () => { + const seq = ++refreshSeqRef.current; setModelsError(null); try { const rows = await invoke('list_openai_models'); + if (seq !== refreshSeqRef.current) return; setModels(Array.isArray(rows) ? rows : []); } catch (err) { + if (seq !== refreshSeqRef.current) return; setModels(null); setModelsError(String(err)); }