From 31e8504dd6dfd0277d0e2fde702825961407d30f Mon Sep 17 00:00:00 2001 From: ashtonanderson Date: Thu, 5 Nov 2026 07:35:46 +0000 Subject: [PATCH 1/4] feat(maia): the download no longer has to block the page, and stays one-time About 4 in 10 people who saw the full-page prompt never started the download. Half of new visitors now get the page at once: desktops download by themselves with a small progress card, and phones get the same large prompt with simpler words and "Not now". A button in the empty Maia panel offers it again. Puzzles wait until the puzzle is over; drills only need Maia to start. The other half keep the prompt, and maia_prompt_variant on every event compares the two. Progress now moves (the model is sent compressed, so the browser has no length) and shows the size and time left. A failed download is no longer logged as completed. So the download happens once: the browser is asked to keep the model, the save is only trusted once it has finished, and PostHog hears when a save fails or a saved model has gone. Co-Authored-By: Claude Opus 5.5 --- public/maia-worker.js | 36 +++- src/components/Analysis/Highlight.tsx | 22 ++- src/components/Common/DownloadModelModal.tsx | 158 +++++++++--------- .../Common/MaiaDownloadPrompt.test.tsx | 137 +++++++++++++++ src/components/Common/MaiaDownloadPrompt.tsx | 137 +++++++++++++++ src/components/Common/index.ts | 1 + src/constants/common.ts | 3 + src/contexts/MaiaEngineContext.tsx | 33 +++- src/contexts/WindowSizeContext.tsx | 3 +- .../useAnalysisController/useDeepAnalysis.ts | 2 +- .../useEngineAnalysis.ts | 29 ++-- src/lib/analytics.ts | 26 ++- src/lib/engine/maia.ts | 40 ++++- src/lib/engine/maiaDownloadPrompt.test.ts | 153 +++++++++++++++++ src/lib/engine/maiaDownloadPrompt.ts | 131 +++++++++++++++ src/lib/engine/storage.ts | 3 + src/lib/posthogClient.ts | 9 + src/pages/analysis/[...id].tsx | 12 +- src/pages/analysis/stream/[gameId].tsx | 15 +- .../broadcast/[broadcastId]/[roundId].tsx | 15 +- src/pages/openings/index.tsx | 32 ++-- src/pages/puzzles.tsx | 15 +- src/types/engine.ts | 3 +- 23 files changed, 846 insertions(+), 169 deletions(-) create mode 100644 src/components/Common/MaiaDownloadPrompt.test.tsx create mode 100644 src/components/Common/MaiaDownloadPrompt.tsx create mode 100644 src/lib/engine/maiaDownloadPrompt.test.ts create mode 100644 src/lib/engine/maiaDownloadPrompt.ts diff --git a/public/maia-worker.js b/public/maia-worker.js index 4a97cfb0..6d244083 100644 --- a/public/maia-worker.js +++ b/public/maia-worker.js @@ -110,8 +110,13 @@ async function storeModel(modelUrl, modelVersion, buffer) { const tx = db.transaction([STORE_NAME], 'readwrite') const store = tx.objectStore(STORE_NAME) + // Saved only once the transaction completes: a full disk can still fail it + // after the request itself has succeeded. await new Promise((resolve, reject) => { - const req = store.put({ + tx.oncomplete = () => resolve() + tx.onabort = () => reject(tx.error) + tx.onerror = () => reject(tx.error) + store.put({ id: MODEL_KEY, url: modelUrl, version: modelVersion, @@ -119,8 +124,6 @@ async function storeModel(modelUrl, modelVersion, buffer) { timestamp: Date.now(), size: buffer.byteLength, }) - req.onsuccess = () => resolve() - req.onerror = () => reject(req.error) }) } @@ -175,10 +178,15 @@ self.onmessage = async (e) => { if (response.body && typeof response.body.getReader === 'function') { const reader = response.body.getReader() - const contentLength = +(response.headers.get('Content-Length') || 0) + // A compressed response has no usable length, and the bytes read here + // are decompressed: use the size the page knows. + const contentLength = response.headers.get('Content-Encoding') + ? msg.expectedBytes || 0 + : +(response.headers.get('Content-Length') || 0) const chunks = [] let receivedLength = 0 let lastReportedProgress = 0 + let lastReportedAt = 0 while (true) { const { done, value } = await reader.read() @@ -187,12 +195,18 @@ self.onmessage = async (e) => { receivedLength += value.length if (contentLength > 0) { - const currentProgress = Math.floor( - (receivedLength / contentLength) * 100, + const currentProgress = Math.min( + 99, + Math.floor((receivedLength / contentLength) * 100), ) - if (currentProgress >= lastReportedProgress + 10) { + const now = Date.now() + if ( + currentProgress > lastReportedProgress && + now - lastReportedAt >= 250 + ) { postMessage({ type: 'progress', progress: currentProgress }) lastReportedProgress = currentProgress + lastReportedAt = now } } } @@ -207,7 +221,13 @@ self.onmessage = async (e) => { buffer = new Uint8Array(await response.arrayBuffer()) } - await storeModel(modelUrl, modelVersion, buffer.buffer) + // Maia still works on this page if saving fails, but it will be + // needed again on the next. + try { + await storeModel(modelUrl, modelVersion, buffer.buffer) + } catch (err) { + postMessage({ type: 'store-failed', message: String(err) }) + } await initSession(buffer.buffer) postMessage({ type: 'progress', progress: 100 }) postMessage({ type: 'status', status: 'ready' }) diff --git a/src/components/Analysis/Highlight.tsx b/src/components/Analysis/Highlight.tsx index 38cf5bab..e271dc72 100644 --- a/src/components/Analysis/Highlight.tsx +++ b/src/components/Analysis/Highlight.tsx @@ -11,7 +11,11 @@ import { GameNode, } from 'src/types' import { MAIA_MODELS } from 'src/constants/common' -import { WindowSizeContext } from 'src/contexts' +import { MaiaEngineContext, WindowSizeContext } from 'src/contexts' +import { + MAIA_DOWNLOAD_MB, + startMaiaDownload, +} from 'src/lib/engine/maiaDownloadPrompt' type DescriptionSegment = | { type: 'text'; content: string } @@ -60,6 +64,7 @@ export const Highlight: React.FC = ({ hideStockfishEvalSummary = false, hideWhiteWinRateSummary = false, }: Props) => { + const maiaEngine = useContext(MaiaEngineContext) const { isMobile } = useContext(WindowSizeContext) // Check if current position is checkmate (independent of Stockfish @@ -576,7 +581,20 @@ export const Highlight: React.FC = ({ ) })} - {emptyRows(recommendations.maia?.slice(0, 4).length ?? 0)} +
+ {emptyRows(recommendations.maia?.slice(0, 4).length ?? 0)} + {maiaEngine.status === 'no-cache' ? ( + + ) : null} +
= ({ maia }) => { + const percent = useSyncExternalStore( + (listener) => maia?.onDownloadProgress(listener) ?? (() => undefined), + () => maia?.downloadPercent ?? 0, + () => 0, + ) + const elapsed = maia ? (Date.now() - maia.downloadStartedAt) / 1000 : 0 + const timeLeft = downloadTimeLeft(percent, elapsed) + return ( +
+

+ {percent > 0 + ? `${Math.round((MAIA_DOWNLOAD_MB * percent) / 100)} of ${MAIA_DOWNLOAD_MB} MB${timeLeft ? `, ${timeLeft}` : ''}` + : 'Starting download...'} +

+
0 ? percent : 12}%` }} + /> +
+ ) +} interface Props { + maia?: Maia progress: number - download: () => void + download: () => Promise + /** A download that started before this prompt appeared. */ + downloading?: boolean + /** Offers to carry on without Maia, rather than only to leave. */ + onNotNow?: () => void } export const DownloadModelModal: React.FC = ({ + maia, progress, download, + downloading = false, + onNotNow, }: Props) => { - const [isDownloading, setIsDownloading] = useState(false) - const downloadStartTime = useRef(null) + const [startedDownload, setStartedDownload] = useState(false) + const isDownloading = startedDownload || downloading const hasTrackedModalShown = useRef(false) useEffect(() => { @@ -40,30 +75,9 @@ export const DownloadModelModal: React.FC = ({ const handleDownload = async () => { if (isDownloading || progress >= 100) return - setIsDownloading(true) - downloadStartTime.current = Date.now() - - const currentPage = - typeof window !== 'undefined' ? window.location.pathname : 'unknown' - trackDownloadModelInitiated(currentPage) - - try { - await download() - - // Track successful download - if (downloadStartTime.current) { - const downloadTime = (Date.now() - downloadStartTime.current) / 1000 - trackDownloadModelCompleted(downloadTime, currentPage) - } - } catch (error) { - // Track failed download - const errorMessage = - error instanceof Error ? error.message : 'Unknown error' - trackDownloadModelFailed('download_error', 0, currentPage) - console.error('Download failed:', errorMessage) - } finally { - setIsDownloading(false) - } + setStartedDownload(true) + await startMaiaDownload(download, onNotNow ? 'tap' : 'wall') + setStartedDownload(false) } return ( = ({ auto_awesome

- Download Our New Model + Download Maia-3

-
-

- Our newest model, Maia-3, provides richer and more in-depth - analysis, allowing for: -

-
    -
  • - ✨ Detailed move evaluations tailored to different rating levels -
  • -
  • - 🧠 Insights into how players of various strengths approach the - game -
  • -
  • - ⚡ Faster, local analysis without needing to send data to a - server -
  • -
-

- Maia-3 runs entirely on your device and requires a one-time 44 MB - download. -

-
+

+ See which moves people at each rating are likely to play. Maia-3 + runs on your device, so it's a one-time {MAIA_DOWNLOAD_MB} MB + download. +

{isDownloading || progress > 0 ? ( -
-

- {progress > 0 - ? `${Math.round(progress)}%` - : 'Starting download...'} -

-
0 ? progress : 12}%` }} - /> +
+
) : null}
- - - home - -

Return Home

- + {onNotNow ? ( + + ) : ( + + + home + +

Return Home

+ + )}
= ({ )}

- {isDownloading ? 'Downloading...' : 'Download Maia-3'}{' '} - (44 MB) + {isDownloading ? 'Downloading...' : 'Download'}{' '} + + ({MAIA_DOWNLOAD_MB} MB) +

diff --git a/src/components/Common/MaiaDownloadPrompt.test.tsx b/src/components/Common/MaiaDownloadPrompt.test.tsx new file mode 100644 index 00000000..dd241c1b --- /dev/null +++ b/src/components/Common/MaiaDownloadPrompt.test.tsx @@ -0,0 +1,137 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { MaiaDownloadPrompt } from './MaiaDownloadPrompt' +import { trackDownloadModelDismissed } from 'src/lib/analytics' +import { + downloadsWithoutAsking, + maiaPromptVariant, + startMaiaDownload, +} from 'src/lib/engine/maiaDownloadPrompt' +import { MaiaStatus } from 'src/types' + +jest.mock('src/lib/engine/maiaDownloadPrompt', () => ({ + ...jest.requireActual('src/lib/engine/maiaDownloadPrompt'), + downloadsWithoutAsking: jest.fn(), + maiaPromptVariant: jest.fn(), + startMaiaDownload: jest.fn().mockResolvedValue(true), +})) + +jest.mock('src/lib/analytics', () => ({ + trackDownloadModelDismissed: jest.fn(), + trackDownloadModelModalShown: jest.fn(), +})) + +const setup = ( + status: MaiaStatus, + { + variant = 'no_wall', + withoutAsking = true, + wanted, + }: { + variant?: 'wall' | 'no_wall' + withoutAsking?: boolean + wanted?: boolean + } = {}, +) => { + ;(maiaPromptVariant as jest.Mock).mockReturnValue(variant) + ;(downloadsWithoutAsking as jest.Mock).mockReturnValue(withoutAsking) + const downloadModel = jest.fn().mockResolvedValue(true) + const view = render( + , + ) + return { downloadModel, ...view } +} + +describe('MaiaDownloadPrompt', () => { + beforeEach(() => { + jest.clearAllMocks() + window.sessionStorage.clear() + }) + + it('blocks the page, with no way past it, in the full-page group', () => { + setup('no-cache', { variant: 'wall' }) + expect(screen.getByTestId('download-modal')).toBeInTheDocument() + expect(screen.getByText('Return Home')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Not now' }), + ).not.toBeInTheDocument() + expect(startMaiaDownload).not.toHaveBeenCalled() + }) + + it('downloads without asking on a desktop, and does not block', async () => { + const { downloadModel } = setup('no-cache') + await act(async () => undefined) + expect(startMaiaDownload).toHaveBeenCalledTimes(1) + expect(startMaiaDownload).toHaveBeenCalledWith(downloadModel, 'auto') + expect(screen.queryByTestId('download-modal')).not.toBeInTheDocument() + }) + + it('asks on a phone, with the large prompt and "Not now"', async () => { + setup('no-cache', { withoutAsking: false }) + await act(async () => undefined) + expect(startMaiaDownload).not.toHaveBeenCalled() + expect(screen.getByTestId('download-modal')).toBeInTheDocument() + expect(screen.queryByText('Return Home')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Download/ })) + }) + expect(startMaiaDownload).toHaveBeenCalledWith(expect.any(Function), 'tap') + }) + + it('stays away after "Not now" until the session ends', async () => { + setup('no-cache', { withoutAsking: false }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Not now' })) + }) + expect(trackDownloadModelDismissed).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId('download-modal')).not.toBeInTheDocument() + + const again = setup('no-cache', { withoutAsking: false }) + expect( + again.container.querySelector('[data-testid=download-modal]'), + ).toBeNull() + }) + + it('does nothing until the page wants Maia', async () => { + setup('no-cache', { wanted: false }) + await act(async () => undefined) + expect(startMaiaDownload).not.toHaveBeenCalled() + expect(screen.queryByTestId('maia-download-card')).not.toBeInTheDocument() + expect(screen.queryByTestId('download-modal')).not.toBeInTheDocument() + }) + + it('shows a card, not the prompt, during a download', () => { + setup('downloading') + expect(screen.getByTestId('maia-download-card')).toBeInTheDocument() + expect(screen.queryByTestId('download-modal')).not.toBeInTheDocument() + }) + + it('offers to try again after a failed download', async () => { + const downloadModel = jest.fn().mockResolvedValue(false) + ;(maiaPromptVariant as jest.Mock).mockReturnValue('no_wall') + ;(downloadsWithoutAsking as jest.Mock).mockReturnValue(true) + const { rerender } = render( + , + ) + rerender( + , + ) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + }) + expect(startMaiaDownload).toHaveBeenCalledWith(downloadModel, 'tap') + }) + + it('shows nothing once Maia is ready', () => { + setup('ready') + expect(screen.queryByTestId('maia-download-card')).not.toBeInTheDocument() + expect(screen.queryByTestId('download-modal')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Common/MaiaDownloadPrompt.tsx b/src/components/Common/MaiaDownloadPrompt.tsx new file mode 100644 index 00000000..dde9df05 --- /dev/null +++ b/src/components/Common/MaiaDownloadPrompt.tsx @@ -0,0 +1,137 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { useEffect, useRef, useState } from 'react' +import { + DownloadModelModal, + MaiaDownloadProgress, +} from 'src/components/Common/DownloadModelModal' +import { + downloadsWithoutAsking, + maiaPromptVariant, + startMaiaDownload, +} from 'src/lib/engine/maiaDownloadPrompt' +import { trackDownloadModelDismissed } from 'src/lib/analytics' +import { MaiaEngine } from 'src/types' + +interface Props { + engine: Pick + /** False until the page has a use for Maia, as during a puzzle. */ + wanted?: boolean +} + +const DISMISSED_KEY = 'maia.downloadOfferDismissed' + +const wasDismissed = () => { + try { + return window.sessionStorage.getItem(DISMISSED_KEY) === '1' + } catch { + return false + } +} + +/** + * Asks for the Maia model, or fetches it (see maiaDownloadPrompt). One group + * gets the prompt that blocks the page. In the other, desktops download without + * asking, and phones get the same prompt but can say "Not now"; the page stays + * usable during the download, with a small card showing how it's going. + */ +export const MaiaDownloadPrompt: React.FC = ({ + engine, + wanted = true, +}: Props) => { + const { maia, status, progress, downloadModel } = engine + const needed = status === 'no-cache' || status === 'downloading' + const [dismissed, setDismissed] = useState(false) + const autoStarted = useRef(false) + const downloadSeen = useRef(false) + if (status === 'downloading') downloadSeen.current = true + + const usesWall = needed && maiaPromptVariant() === 'wall' + const asks = status === 'no-cache' && wanted && !usesWall + const auto = asks && downloadsWithoutAsking() + + useEffect(() => { + if (auto && !autoStarted.current) { + autoStarted.current = true + startMaiaDownload(downloadModel, 'auto') + } + }, [auto, downloadModel]) + + const dismiss = () => { + setDismissed(true) + trackDownloadModelDismissed(window.location.pathname) + try { + window.sessionStorage.setItem(DISMISSED_KEY, '1') + } catch { + // It comes back on the next page. + } + } + + if (usesWall) { + return ( + + + + ) + } + + if (asks && !auto && !dismissed && !wasDismissed()) { + return ( + + + + ) + } + + const downloading = status === 'downloading' + const failed = status === 'error' && downloadSeen.current + + return ( + + {downloading || failed ? ( + +
+ {downloading ? ( + <> +

Downloading Maia-3

+ + + ) : ( + <> +

+ Maia-3 didn't finish downloading. +

+
+ +
+ + )} +
+
+ ) : null} +
+ ) +} diff --git a/src/components/Common/index.ts b/src/components/Common/index.ts index c275e024..338513c6 100644 --- a/src/components/Common/index.ts +++ b/src/components/Common/index.ts @@ -16,5 +16,6 @@ export * from './ModalContainer' export * from './ContinueAgainstMaia' export * from './AnimatedNumber' export * from './DownloadModelModal' +export * from './MaiaDownloadPrompt' export * from './ResignationConfirmModal' export * from './UnderlineTabs' diff --git a/src/constants/common.ts b/src/constants/common.ts index dfbd99c2..8a4b1ba9 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -16,3 +16,6 @@ export const MAIA3_OPPONENT_RATINGS = Array.from({ length: 21 }, (_, i) => { const rating = 600 + i * 100 return { id: `maia_kdd_${rating}`, name: `Maia-3 ${rating}` } }) + +/** The shorter side of a phone's screen is at most this. */ +export const PHONE_BREAKPOINT_PX = 670 diff --git a/src/contexts/MaiaEngineContext.tsx b/src/contexts/MaiaEngineContext.tsx index b82dbe56..619fb6f6 100644 --- a/src/contexts/MaiaEngineContext.tsx +++ b/src/contexts/MaiaEngineContext.tsx @@ -1,4 +1,14 @@ -import { trackMaiaEngineFailed, trackMaiaEngineReady } from 'src/lib/analytics' +import { + trackMaiaEngineFailed, + trackMaiaEngineReady, + trackMaiaModelLost, + trackMaiaModelProtected, + trackMaiaModelStoreFailed, +} from 'src/lib/analytics' +import { + protectMaiaModel, + takeLostMaiaModelAge, +} from 'src/lib/engine/maiaDownloadPrompt' import Maia from '../lib/engine/maia' import { MaiaStatus, MaiaEngine } from 'src/types' import React, { @@ -33,6 +43,8 @@ export const MaiaEngineContextProvider: React.FC<{ children: ReactNode }> = ({ const toastId = useRef(null) const loadingToastTimerRef = useRef(null) + const storeFailed = useRef(false) + const maia = useMemo(() => { const model = new Maia({ model: @@ -42,6 +54,10 @@ export const MaiaEngineContextProvider: React.FC<{ children: ReactNode }> = ({ setStatus: setStatus, setProgress: setProgress, setError: setError, + onStoreFailed: (message) => { + storeFailed.current = true + trackMaiaModelStoreFailed(message) + }, }) return model }, []) @@ -50,9 +66,11 @@ export const MaiaEngineContextProvider: React.FC<{ children: ReactNode }> = ({ try { setStatus('downloading') await maia.downloadModel() + return true } catch (err) { setError(err instanceof Error ? err.message : 'Failed to download model') setStatus('error') + return false } }, [maia]) @@ -64,6 +82,19 @@ export const MaiaEngineContextProvider: React.FC<{ children: ReactNode }> = ({ return await maia.clearStorage() }, [maia]) + // The download should only ever happen once: ask the browser to keep the + // model, and report it if the browser has lost one it had. + useEffect(() => { + if (status === 'ready' && !storeFailed.current) { + protectMaiaModel().then((granted) => { + if (granted !== null) trackMaiaModelProtected(granted) + }) + } else if (status === 'no-cache') { + const days = takeLostMaiaModelAge() + if (days !== null) trackMaiaModelLost(days) + } + }, [status]) + // How Maia's start went, once a page (a download doesn't count as a start). const startedAt = useRef(Date.now()) const startReported = useRef(false) diff --git a/src/contexts/WindowSizeContext.tsx b/src/contexts/WindowSizeContext.tsx index a40a5f08..4930d93c 100644 --- a/src/contexts/WindowSizeContext.tsx +++ b/src/contexts/WindowSizeContext.tsx @@ -1,7 +1,8 @@ import { useWindowSize } from 'src/hooks' import React, { ReactNode, useMemo } from 'react' +import { PHONE_BREAKPOINT_PX } from 'src/constants/common' -export const PHONE_BREAKPOINT_PX = 670 +export { PHONE_BREAKPOINT_PX } export const TABLET_BREAKPOINT_PX = 1024 interface IWindowSizeContext { diff --git a/src/hooks/useAnalysisController/useDeepAnalysis.ts b/src/hooks/useAnalysisController/useDeepAnalysis.ts index d79cd8e0..c6c1e833 100644 --- a/src/hooks/useAnalysisController/useDeepAnalysis.ts +++ b/src/hooks/useAnalysisController/useDeepAnalysis.ts @@ -68,7 +68,7 @@ export const useDeepAnalysis = ({ while ( retries < maxRetries && - (!stockfish.isReady() || maia.status !== 'ready') && + (!stockfish.isReady() || maia.status === 'loading') && !deepAnalysisStatus.current.cancelled ) { await new Promise((resolve) => setTimeout(resolve, 100)) diff --git a/src/hooks/useAnalysisController/useEngineAnalysis.ts b/src/hooks/useAnalysisController/useEngineAnalysis.ts index bf7d4946..3429306d 100644 --- a/src/hooks/useAnalysisController/useEngineAnalysis.ts +++ b/src/hooks/useAnalysisController/useEngineAnalysis.ts @@ -13,11 +13,8 @@ import { STOCKFISH_DEBUG_RERUN_KEY, } from 'src/constants/analysis' import { GameNode, MaiaEvaluation } from 'src/types' -import { - MaiaEngineContext, - PHONE_BREAKPOINT_PX, - StockfishEngineContext, -} from 'src/contexts' +import { MaiaEngineContext, StockfishEngineContext } from 'src/contexts' +import { isPhone } from 'src/lib/engine/maiaDownloadPrompt' /** Most ratings per Maia request (~0.55 s for 11 on a desktop). */ const MAIA_BATCH_SIZE = 11 @@ -38,19 +35,6 @@ const STOCKFISH_RENDER_INTERVAL_MS = 150 */ const ANALYSIS_DELAY_MS = 50 -/** - * A phone: a touch screen whose shorter side is a phone's. Each Maia rating - * takes ~0.22 s there (~0.05 s on a desktop), so phones analyse every 200 - * points only, in one request, and the moves-by-rating chart interpolates the - * ratings in between. Every 400 points first drew the chart ~0.9 s sooner on - * an emulated phone, but Stockfish reached depth 12 ~0.5 s later: the chart - * then redraws for Stockfish's changing top moves through more of its search. - */ -const isPhone = () => - typeof window !== 'undefined' && - window.matchMedia('(pointer: coarse)').matches && - Math.min(window.screen.width, window.screen.height) <= PHONE_BREAKPOINT_PX - export const useEngineAnalysis = ( currentNode: GameNode | null, inProgressAnalyses: Set, @@ -155,7 +139,11 @@ export const useEngineAnalysis = ( // moves show at once. Then, on a desktop, every 400 points from 600 to // 2600, from which the moves-by-rating chart is drawn (the ratings in // between interpolated), the rest of every 200 points, and those in - // between; on a phone every 200 points and no more. Each stage is one + // between; on a phone every 200 points and no more (each rating takes + // ~0.22 s there, ~0.05 s on a desktop; every 400 points first drew the + // chart ~0.9 s sooner on an emulated phone, but Stockfish reached depth 12 + // ~0.5 s later, as the chart redraws for its changing top moves through + // more of its search). Each stage is one // request, so a newer position never waits long behind it, and is added // as it arrives, keeping what the node already has. const ratings = (keep: (index: number) => boolean) => @@ -183,6 +171,9 @@ export const useEngineAnalysis = ( ) return + // Runs again when the model arrives. + if (maia.status === 'no-cache' || maia.status === 'downloading') return + // Add retry logic for Maia initialization let retries = 0 const maxRetries = 30 // 3 seconds with 100ms intervals diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index b76cda92..3246bc2a 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -24,14 +24,38 @@ export const trackDownloadModelModalShown = ( safeTrack('download_model_modal_shown', { trigger, page }) } -export const trackDownloadModelInitiated = (page: string) => { +export const trackDownloadModelInitiated = ( + page: string, + trigger: 'wall' | 'auto' | 'tap' | 'panel', +) => { safeTrack('download_model_initiated', { model_type: 'maia2', model_size_mb: 90, page, + trigger, }) } +/** The model has been saved, and whether the browser promises to keep it. */ +export const trackMaiaModelProtected = (granted: boolean) => { + safeTrack('maia_model_protected', { granted }) +} + +/** Downloaded, but the browser wouldn't save it: it's needed again next time. */ +export const trackMaiaModelStoreFailed = (message: string) => { + safeTrack('maia_model_store_failed', { message }) +} + +/** The browser no longer has a model it had saved. */ +export const trackMaiaModelLost = (daysSinceSaved: number) => { + safeTrack('maia_model_lost', { days_since_saved: daysSinceSaved }) +} + +/** "Not now" on the download prompt. */ +export const trackDownloadModelDismissed = (page: string) => { + safeTrack('download_model_dismissed', { page }) +} + export const trackDownloadModelCompleted = ( downloadTimeSeconds: number, page: string, diff --git a/src/lib/engine/maia.ts b/src/lib/engine/maia.ts index c35bdd55..a679fde6 100644 --- a/src/lib/engine/maia.ts +++ b/src/lib/engine/maia.ts @@ -27,6 +27,8 @@ interface MaiaOptions { setStatus: (status: MaiaStatus) => void setProgress: (progress: number) => void setError: (error: string) => void + /** Downloaded, but the browser wouldn't save it. */ + onStoreFailed?: (message: string) => void } interface PendingInference { @@ -51,6 +53,9 @@ interface QueuedInference extends PendingInference { const abortError = () => new DOMException('Inference aborted', 'AbortError') +/** maia3_simplified.onnx, which is sent compressed and so without a length. */ +const MAIA_MODEL_BYTES = 45_683_686 + /** * Starting ONNX Runtime reserves a shared WebAssembly memory of up to 4 GB. * On an iPhone, reloading the analysis page can fail that reservation @@ -70,6 +75,11 @@ interface PendingDownload { class Maia { private worker: Worker | null = null + private downloadListeners = new Set<() => void>() + private reportedTens = 0 + /** How far the download is, for showing it. */ + public downloadPercent = 0 + public downloadStartedAt = 0 private options: MaiaOptions private storage: MaiaModelStorage private pendingInferences: Map = new Map() @@ -111,8 +121,19 @@ class Maia { } break - case 'progress': - this.options.setProgress(msg.progress) + case 'progress': { + this.downloadPercent = msg.progress + this.downloadListeners.forEach((listener) => listener()) + const tens = Math.floor(msg.progress / 10) * 10 + if (tens !== this.reportedTens) { + this.reportedTens = tens + this.options.setProgress(tens) + } + break + } + + case 'store-failed': + this.options.onStoreFailed?.(msg.message) break case 'error': { @@ -190,17 +211,30 @@ class Maia { return this.restarts } + public onDownloadProgress = (listener: () => void) => { + this.downloadListeners.add(listener) + return () => { + this.downloadListeners.delete(listener) + } + } + public async downloadModel() { if (!this.worker) throw new Error('Worker not initialized') if (this.downloadPromise) { return this.downloadPromise } + this.reportedTens = 0 + this.downloadPercent = 0 + this.downloadStartedAt = Date.now() this.options.setProgress(0) this.downloadPromise = new Promise((resolve, reject) => { this.pendingDownload = { resolve, reject } - this.worker!.postMessage({ type: 'download' }) + this.worker!.postMessage({ + type: 'download', + expectedBytes: MAIA_MODEL_BYTES, + }) }) return this.downloadPromise diff --git a/src/lib/engine/maiaDownloadPrompt.test.ts b/src/lib/engine/maiaDownloadPrompt.test.ts new file mode 100644 index 00000000..daa01323 --- /dev/null +++ b/src/lib/engine/maiaDownloadPrompt.test.ts @@ -0,0 +1,153 @@ +jest.mock('src/lib/analytics', () => ({ + trackDownloadModelCompleted: jest.fn(), + trackDownloadModelFailed: jest.fn(), + trackDownloadModelInitiated: jest.fn(), +})) + +type Prompt = typeof import('./maiaDownloadPrompt') + +const load = () => { + let loaded = {} as Prompt + jest.isolateModules(() => { + loaded = jest.requireActual('./maiaDownloadPrompt') + }) + return loaded +} + +const setPhone = (phone: boolean) => { + window.matchMedia = jest.fn().mockReturnValue({ matches: phone }) + Object.defineProperty(window.screen, 'width', { + value: phone ? 390 : 1600, + configurable: true, + }) + Object.defineProperty(window.screen, 'height', { + value: phone ? 844 : 1000, + configurable: true, + }) +} + +const setConnection = (connection?: { + saveData?: boolean + effectiveType?: string +}) => { + Object.defineProperty(navigator, 'connection', { + value: connection, + configurable: true, + }) +} + +const setPersist = (persist?: () => Promise) => { + Object.defineProperty(navigator, 'storage', { + value: persist ? { persist } : undefined, + configurable: true, + }) +} + +describe('maiaDownloadPrompt', () => { + beforeEach(() => { + jest.clearAllMocks() + window.localStorage.clear() + setPhone(false) + setConnection(undefined) + setPersist(undefined) + }) + + describe('maiaPromptVariant', () => { + it('keeps a visitor in one group across visits', () => { + const first = load().maiaPromptVariant() + expect(load().maiaPromptVariant()).toBe(first) + expect(window.localStorage.getItem('maia.downloadPromptVariant')).toBe( + first, + ) + }) + + it('splits new visitors in two', () => { + const random = jest.spyOn(Math, 'random') + random.mockReturnValue(0.25) + expect(load().maiaPromptVariant()).toBe('wall') + window.localStorage.clear() + random.mockReturnValue(0.75) + expect(load().maiaPromptVariant()).toBe('no_wall') + random.mockRestore() + }) + }) + + describe('downloadsWithoutAsking', () => { + it('is true on a desktop', () => { + expect(load().downloadsWithoutAsking()).toBe(true) + }) + + it('is false on a phone', () => { + setPhone(true) + expect(load().downloadsWithoutAsking()).toBe(false) + }) + + it('is false when saving data or on a very slow connection', () => { + setConnection({ saveData: true }) + expect(load().downloadsWithoutAsking()).toBe(false) + setConnection({ effectiveType: '2g' }) + expect(load().downloadsWithoutAsking()).toBe(false) + setConnection({ effectiveType: '4g' }) + expect(load().downloadsWithoutAsking()).toBe(true) + }) + }) + + describe('downloadTimeLeft', () => { + it('waits until the pace means something', () => { + const { downloadTimeLeft } = load() + expect(downloadTimeLeft(1, 5)).toBeNull() + expect(downloadTimeLeft(50, 1)).toBeNull() + expect(downloadTimeLeft(100, 10)).toBeNull() + }) + + it('rounds to something easy to read', () => { + const { downloadTimeLeft } = load() + expect(downloadTimeLeft(90, 9)).toBe('a few seconds left') + expect(downloadTimeLeft(50, 11)).toBe('about 15 s left') + expect(downloadTimeLeft(10, 30)).toBe('about 5 min left') + }) + }) + + describe('startMaiaDownload', () => { + it('counts a failed download as failed', async () => { + const analytics = jest.requireMock('src/lib/analytics') + const { startMaiaDownload } = load() + await startMaiaDownload(async () => false, 'tap') + expect(analytics.trackDownloadModelFailed).toHaveBeenCalledTimes(1) + expect(analytics.trackDownloadModelCompleted).not.toHaveBeenCalled() + await startMaiaDownload(async () => true, 'tap') + expect(analytics.trackDownloadModelCompleted).toHaveBeenCalledTimes(1) + }) + }) + + describe('keeping the model', () => { + it('asks the browser once', async () => { + const persist = jest.fn().mockResolvedValue(true) + setPersist(persist) + const { protectMaiaModel } = load() + expect(await protectMaiaModel()).toBe(true) + expect(await protectMaiaModel()).toBeNull() + expect(persist).toHaveBeenCalledTimes(1) + }) + + it('reports a model the browser has lost, once', async () => { + const { protectMaiaModel, takeLostMaiaModelAge } = load() + expect(takeLostMaiaModelAge()).toBeNull() + await protectMaiaModel() + window.localStorage.setItem( + 'maia.modelSavedAt', + String(Date.now() - 3 * 86_400_000), + ) + expect(takeLostMaiaModelAge()).toBe(3) + expect(takeLostMaiaModelAge()).toBeNull() + }) + + it('does not count a model the visitor deleted as lost', async () => { + const { protectMaiaModel, takeLostMaiaModelAge, forgetMaiaModelSaved } = + load() + await protectMaiaModel() + forgetMaiaModelSaved() + expect(takeLostMaiaModelAge()).toBeNull() + }) + }) +}) diff --git a/src/lib/engine/maiaDownloadPrompt.ts b/src/lib/engine/maiaDownloadPrompt.ts new file mode 100644 index 00000000..a74f2fa0 --- /dev/null +++ b/src/lib/engine/maiaDownloadPrompt.ts @@ -0,0 +1,131 @@ +import { PHONE_BREAKPOINT_PX } from 'src/constants/common' +import { + trackDownloadModelCompleted, + trackDownloadModelFailed, + trackDownloadModelInitiated, +} from 'src/lib/analytics' +import { registerProperties } from 'src/lib/posthogClient' + +// How a visitor without the Maia model is asked for it. About 4 in 10 people +// who saw the full-page prompt never got past it, so half of new visitors now +// get the page at once. Each visitor stays in one group, and every event they +// send says which, so the two can be compared in PostHog. +export type MaiaPromptVariant = 'wall' | 'no_wall' + +const VARIANT_KEY = 'maia.downloadPromptVariant' + +let variant: MaiaPromptVariant | null = null + +export const maiaPromptVariant = (): MaiaPromptVariant => { + if (variant) return variant + try { + const stored = window.localStorage.getItem(VARIANT_KEY) + if (stored === 'wall' || stored === 'no_wall') { + variant = stored + } else { + variant = Math.random() < 0.5 ? 'wall' : 'no_wall' + window.localStorage.setItem(VARIANT_KEY, variant) + } + } catch { + variant = 'no_wall' + } + registerProperties({ maia_prompt_variant: variant }) + return variant +} + +/** A touch screen whose shorter side is a phone's. */ +export const isPhone = () => + typeof window !== 'undefined' && + window.matchMedia('(pointer: coarse)').matches && + Math.min(window.screen.width, window.screen.height) <= PHONE_BREAKPOINT_PX + +/** + * The model is large. Phones may be on mobile data, and iPhones don't say + * whether they are, so phones are asked. So is anyone whose browser asks to + * save data or reports a very slow connection. + */ +export const downloadsWithoutAsking = () => { + const connection = ( + navigator as Navigator & { + connection?: { saveData?: boolean; effectiveType?: string } + } + ).connection + return ( + !isPhone() && + !connection?.saveData && + !connection?.effectiveType?.includes('2g') + ) +} + +const SAVED_KEY = 'maia.modelSavedAt' + +/** + * Asks the browser to keep the model when it runs short of space, the first + * time this browser is seen with it. Returns whether it agreed, or null if + * nothing was asked. Chrome and Safari decide by themselves; Firefox would show + * a permission popup, so it isn't asked. + */ +export const protectMaiaModel = async (): Promise => { + try { + if (window.localStorage.getItem(SAVED_KEY)) return null + window.localStorage.setItem(SAVED_KEY, String(Date.now())) + if (/firefox/i.test(navigator.userAgent) || !navigator.storage?.persist) { + return false + } + return await navigator.storage.persist() + } catch { + return null + } +} + +/** Days since a model this browser no longer has was saved, once; else null. */ +export const takeLostMaiaModelAge = (): number | null => { + try { + const saved = Number(window.localStorage.getItem(SAVED_KEY)) + if (!saved) return null + window.localStorage.removeItem(SAVED_KEY) + return Math.round((Date.now() - saved) / 86_400_000) + } catch { + return null + } +} + +/** The visitor deleted the model on purpose: that isn't losing it. */ +export const forgetMaiaModelSaved = () => { + try { + window.localStorage.removeItem(SAVED_KEY) + } catch { + // Nothing to forget. + } +} + +/** What the download uses, sent compressed: 41.7 MB. */ +export const MAIA_DOWNLOAD_MB = 42 + +export type MaiaDownloadTrigger = 'wall' | 'auto' | 'tap' | 'panel' + +/** Starts the download and records how it went. */ +export const startMaiaDownload = async ( + download: () => Promise, + trigger: MaiaDownloadTrigger, +) => { + const page = window.location.pathname + const startedAt = Date.now() + trackDownloadModelInitiated(page, trigger) + const downloaded = await download() + if (downloaded) { + trackDownloadModelCompleted((Date.now() - startedAt) / 1000, page) + } else { + trackDownloadModelFailed('download_error', 0, page) + } + return downloaded +} + +/** "about 20 s left", from the pace so far. Null until that means anything. */ +export const downloadTimeLeft = (percent: number, elapsedSeconds: number) => { + if (percent < 3 || percent >= 100 || elapsedSeconds < 2) return null + const seconds = (elapsedSeconds * (100 - percent)) / percent + if (seconds < 8) return 'a few seconds left' + if (seconds < 90) return `about ${Math.ceil(seconds / 5) * 5} s left` + return `about ${Math.round(seconds / 60)} min left` +} diff --git a/src/lib/engine/storage.ts b/src/lib/engine/storage.ts index 21b06825..847ff230 100644 --- a/src/lib/engine/storage.ts +++ b/src/lib/engine/storage.ts @@ -1,3 +1,5 @@ +import { forgetMaiaModelSaved } from './maiaDownloadPrompt' + interface ModelStorage { id: string url: string @@ -230,6 +232,7 @@ export class MaiaModelStorage { } async clearAllStorage(): Promise { + forgetMaiaModelSaved() try { await this.deleteModel() console.log('Maia storage cleared') diff --git a/src/lib/posthogClient.ts b/src/lib/posthogClient.ts index 18b40c91..ba386da7 100644 --- a/src/lib/posthogClient.ts +++ b/src/lib/posthogClient.ts @@ -10,6 +10,14 @@ const pending: [string, Properties | undefined, Date][] = [] let client: PostHog | null = null let loading: Promise | null = null +const registered: Properties = {} + +/** Sent with every event from now on, and on later visits. */ +export const registerProperties = (properties: Properties) => { + Object.assign(registered, properties) + client?.register(properties) +} + export const capture = (event: string, properties?: Properties) => { if (client) { client.capture(event, properties) @@ -31,6 +39,7 @@ export const loadPostHog = () => { browserPostHogConfig, ) client = posthog + posthog.register(registered) // Sent with the time they happened. for (const [event, properties, timestamp] of pending.splice(0)) { posthog.capture(event, properties, { timestamp }) diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 78712e72..de8913cb 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -45,7 +45,7 @@ import { } from 'src/components/Analysis/BoardChrome' import { MovesByRating } from 'src/components/Analysis/MovesByRating' import { AnalysisGameList } from 'src/components/Analysis/AnalysisGameList' -import { DownloadModelModal } from 'src/components/Common/DownloadModelModal' +import { MaiaDownloadPrompt } from 'src/components/Common/MaiaDownloadPrompt' import { CustomAnalysisModal } from 'src/components/Analysis/CustomAnalysisModal' import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens' import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal' @@ -2343,15 +2343,7 @@ const Analysis: React.FC = ({ whitePlayer={analyzedGame?.whitePlayer.name} blackPlayer={analyzedGame?.blackPlayer.name} /> - - {controller.maia.status === 'no-cache' || - controller.maia.status === 'downloading' ? ( - - ) : null} - + {analyzedGame && (
diff --git a/src/pages/analysis/stream/[gameId].tsx b/src/pages/analysis/stream/[gameId].tsx index d73e2f7e..d978958c 100644 --- a/src/pages/analysis/stream/[gameId].tsx +++ b/src/pages/analysis/stream/[gameId].tsx @@ -6,7 +6,7 @@ import { AnimatePresence } from 'framer-motion' import { Loading } from 'src/components' import { AuthenticatedWrapper } from 'src/components/Common/AuthenticatedWrapper' -import { DownloadModelModal } from 'src/components/Common/DownloadModelModal' +import { MaiaDownloadPrompt } from 'src/components/Common/MaiaDownloadPrompt' import { useLichessStreamController } from 'src/hooks/useLichessStreamController' import { useAnalysisController } from 'src/hooks' import { TreeControllerContext } from 'src/contexts' @@ -174,16 +174,9 @@ const StreamAnalysisPage: NextPage = () => { )} - - {analysisController && - (analysisController.maia.status === 'no-cache' || - analysisController.maia.status === 'downloading') ? ( - - ) : null} - + {analysisController ? ( + + ) : null} {!streamController.game?.loaded && diff --git a/src/pages/broadcast/[broadcastId]/[roundId].tsx b/src/pages/broadcast/[broadcastId]/[roundId].tsx index 2bc6dd5d..b6bbb10a 100644 --- a/src/pages/broadcast/[broadcastId]/[roundId].tsx +++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx @@ -6,7 +6,7 @@ import { AnimatePresence } from 'framer-motion' import { Loading } from 'src/components' import { AuthenticatedWrapper } from 'src/components/Common/AuthenticatedWrapper' -import { DownloadModelModal } from 'src/components/Common/DownloadModelModal' +import { MaiaDownloadPrompt } from 'src/components/Common/MaiaDownloadPrompt' import { useBroadcastController } from 'src/hooks/useBroadcastController' import { useAnalysisController } from 'src/hooks' import { TreeControllerContext } from 'src/contexts' @@ -257,16 +257,9 @@ const BroadcastAnalysisPage: NextPage = () => { - - {analysisController && - (analysisController.maia.status === 'no-cache' || - analysisController.maia.status === 'downloading') ? ( - - ) : null} - + {analysisController ? ( + + ) : null} {!(broadcastController as any).currentLiveGame?.loaded && diff --git a/src/pages/openings/index.tsx b/src/pages/openings/index.tsx index b76aa1ed..9c98acf5 100644 --- a/src/pages/openings/index.tsx +++ b/src/pages/openings/index.tsx @@ -37,6 +37,7 @@ import { GameBoard, PromotionOverlay, DownloadModelModal, + MaiaDownloadPrompt, AuthenticatedWrapper, AnalysisNotification, AnalysisOverlay, @@ -66,6 +67,10 @@ import { cpToWinrate } from 'src/lib/analysis' import { isValidFen, normalizeFen } from 'src/lib/positionLinks' import { useOpeningDrillController, useAnalysisController } from 'src/hooks' +import { + downloadsWithoutAsking, + maiaPromptVariant, +} from 'src/lib/engine/maiaDownloadPrompt' import { getCurrentPlayer, getAvailableMovesArray, @@ -1147,17 +1152,20 @@ const OpeningsPage: NextPage = () => { // return null // } - // Show download modal if Maia model needs to be downloaded - if (maiaEngine.status === 'no-cache' || maiaEngine.status === 'downloading') { + // Maia is needed once a drill starts. In the group without the full-page + // prompt, not while choosing what to drill. + const maiaMissing = + maiaEngine.status === 'no-cache' || maiaEngine.status === 'downloading' + if (maiaMissing && (!showSelectionModal || maiaPromptVariant() === 'wall')) { return ( - <> - - - - + + + ) } @@ -1165,6 +1173,10 @@ const OpeningsPage: NextPage = () => { if (showSelectionModal) { return ( <> + = ({ return ( <> - - {analysisController.maia.status === 'no-cache' || - analysisController.maia.status === 'downloading' ? ( - - ) : null} - + {/* No fade-in when the puzzle replaces the shell, which already shows the panels and board. */} diff --git a/src/types/engine.ts b/src/types/engine.ts index 006e7561..16e45a28 100644 --- a/src/types/engine.ts +++ b/src/types/engine.ts @@ -34,7 +34,8 @@ export interface MaiaEngine { maia?: Maia status: MaiaStatus progress: number - downloadModel: () => Promise + /** Whether the model downloaded. */ + downloadModel: () => Promise } export interface StockfishEngine { From da8cef7be7f00d0dc44d8dc33b5cc34c0b319669 Mon Sep 17 00:00:00 2001 From: ashtonanderson Date: Thu, 5 Nov 2026 07:35:46 +0000 Subject: [PATCH 2/4] docs: LOADING.md: round 13, getting past the Maia download Co-Authored-By: Claude Opus 5.5 --- LOADING.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/LOADING.md b/LOADING.md index 02ed6fda..4c48bc8f 100644 --- a/LOADING.md +++ b/LOADING.md @@ -1485,6 +1485,81 @@ WHERE event IN ('stockfish_engine_ready', 'stockfish_engine_failed') AND timesta GROUP BY after_round_10 ``` +### Round 13: getting past the Maia download + +PostHog showed the biggest loss on the site isn't a slow page. Of the 7,631 people who +saw the full-page Maia download prompt in 30 days, 37.6% never started the download +(funnel to 2026-11-05), about 780 people in the last full week, or 11% of that week's +visitors. Of those who didn't start, 65% did nothing else within a minute, against 25% of +those who did. Both groups differ in other ways, so that doesn't prove the prompt caused it. + +Stockfish doesn't wait for Maia, so nothing about the page needs the prompt to block it. + +| what changed | before | after | +| ------------------------ | ----------------------------------- | --------------------------------------------------------------------------- | +| desktop | full-page prompt, needs a click | page opens, Maia downloads by itself, small card shows progress | +| phone (or save-data, 2g) | full-page prompt, only exit is home | same size prompt, simpler words, "Not now" opens the page | +| after "Not now" | - | button in the empty Maia panel, and the prompt again in a new tab | +| puzzles | prompt on arrival | nothing until the puzzle is over | +| drills | prompt on arrival | choosing a drill isn't blocked; Maia is needed to start one | +| progress | stuck on "Starting download..." | size and time left: "12 of 42 MB, about 20 s left" | +| failed download | logged as completed | logged as failed, with "Try again" | +| saved model | browser free to delete it | browser asked to keep it (not Firefox, which would show a popup) | +| saving it | "saved" before the save was final | waits for the save; a failure is reported, and Maia still runs on that page | + +The progress bar never moved because the model is sent with brotli, so the browser gets no +`Content-Length`. The page now knows the size. Progress reaches the page's state in 10% +steps as before, and the bar reads finer steps directly, so the page doesn't re-render more. + +"42 MB" is what the download uses: 41,688,610 bytes as sent, 45,683,686 once decompressed. + +**Half of new visitors keep the prompt.** `maia_prompt_variant` is `wall` or `no_wall`, +chosen once per browser only when it has no model, and sent with every event, so the groups +can be compared for two weeks. On phones the comparison is "Return Home" against "Not now". + +**Stockfish** (test browser, model at about the median 9 s, paired medians, later minus +earlier; phone-sized is not slowed down): + +| comparison | visits | d12 | d18 | +| -------------------------------------------- | ------- | ------ | ------ | +| return visit, desktop: this against main | 8 pairs | +46 ms | -5 ms | +| return visit, phone-sized: this against main | 8 pairs | -56 ms | -38 ms | +| first visit, desktop: downloading or not | 6 pairs | -3 ms | +30 ms | + +All are within the spread between visits. In an earlier run of the first-visit comparison, +2 of 6 visits reached d18 up to 0.5 s later while downloading. On a first desktop visit +Maia's moves appeared about 11.8 s after the board, with no click. + +**Asked again after finishing the download** (60 days, per person, operating system and +browser; clearing cookies looks like a new person, so these are low): + +| OS | asked again at all | within an hour | of those back a day or more later | +| ------- | ------------------ | -------------- | --------------------------------- | +| iOS | 7.9% | 5.7% | 4.7% | +| Android | 7.0% | 3.8% | 12.0% | +| Mac | 4.5% | 2.5% | 6.7% | +| Windows | 2.4% | 1.3% | 3.5% | + +New events say why from now on: `maia_model_lost` (with `days_since_saved`), +`maia_model_store_failed`, `maia_model_protected` (with `granted`). Also new: +`download_model_dismissed`, and `trigger` on `download_model_initiated` (`wall`, `auto`, +`tap`, `panel`). + +```sql +-- Round 13: who gets Maia, and who stays, by group and device +SELECT properties.maia_prompt_variant AS grp, properties.$device_type AS device, + uniqIf(person_id, event IN ('download_model_modal_shown', 'download_model_initiated')) AS needed_maia, + uniqIf(person_id, event = 'download_model_completed') AS got_maia, + uniqIf(person_id, event = 'download_model_dismissed') AS said_not_now, + uniqIf(person_id, event = 'analysis_engine_steps') AS moved_through_a_game +FROM events +WHERE timestamp >= now() - INTERVAL 14 DAY AND properties.maia_prompt_variant IS NOT NULL +GROUP BY grp, device ORDER BY device, grp +``` + +Not tested: a real phone, whether browsers grant the request to keep the model (the test +browser refused it), and starting a drill on a phone. + ## What changed | commit | change | effect | From 040686c445a73294e88b0c5c1510c067d6f6b8f6 Mon Sep 17 00:00:00 2001 From: ashtonanderson Date: Thu, 5 Nov 2026 08:12:05 +0000 Subject: [PATCH 3/4] feat(maia): shorter download prompt, and the panel button only when Maia is empty The prompt is now one line, and download buttons read "Download Maia-3 (42MB)". A game with saved analysis already shows Maia's moves, so the button in the Maia panel no longer covers them. The prompt's buttons no longer clip on narrow phones. Co-Authored-By: Claude Opus 5.5 --- LOADING.md | 4 +-- src/components/Analysis/Highlight.tsx | 5 +-- src/components/Common/DownloadModelModal.tsx | 34 ++++++++----------- .../Common/MaiaDownloadPrompt.test.tsx | 7 ++-- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/LOADING.md b/LOADING.md index 4c48bc8f..59a355c3 100644 --- a/LOADING.md +++ b/LOADING.md @@ -1498,11 +1498,11 @@ Stockfish doesn't wait for Maia, so nothing about the page needs the prompt to b | what changed | before | after | | ------------------------ | ----------------------------------- | --------------------------------------------------------------------------- | | desktop | full-page prompt, needs a click | page opens, Maia downloads by itself, small card shows progress | -| phone (or save-data, 2g) | full-page prompt, only exit is home | same size prompt, simpler words, "Not now" opens the page | +| phone (or save-data, 2g) | full-page prompt, only exit is home | same size prompt, one line of text, "Not now" opens the page | | after "Not now" | - | button in the empty Maia panel, and the prompt again in a new tab | | puzzles | prompt on arrival | nothing until the puzzle is over | | drills | prompt on arrival | choosing a drill isn't blocked; Maia is needed to start one | -| progress | stuck on "Starting download..." | size and time left: "12 of 42 MB, about 20 s left" | +| progress | stuck on "Starting download..." | size and time left: "12 of 42MB, about 20 s left" | | failed download | logged as completed | logged as failed, with "Try again" | | saved model | browser free to delete it | browser asked to keep it (not Firefox, which would show a popup) | | saving it | "saved" before the save was final | waits for the save; a failure is reported, and Maia still runs on that page | diff --git a/src/components/Analysis/Highlight.tsx b/src/components/Analysis/Highlight.tsx index e271dc72..5b3287f6 100644 --- a/src/components/Analysis/Highlight.tsx +++ b/src/components/Analysis/Highlight.tsx @@ -583,7 +583,8 @@ export const Highlight: React.FC = ({ })}
{emptyRows(recommendations.maia?.slice(0, 4).length ?? 0)} - {maiaEngine.status === 'no-cache' ? ( + {maiaEngine.status === 'no-cache' && + !recommendations.maia?.length ? ( ) : null}
diff --git a/src/components/Common/DownloadModelModal.tsx b/src/components/Common/DownloadModelModal.tsx index e3fb3616..9dfe72c7 100644 --- a/src/components/Common/DownloadModelModal.tsx +++ b/src/components/Common/DownloadModelModal.tsx @@ -22,7 +22,7 @@ export const MaiaDownloadProgress: React.FC<{ maia?: Maia }> = ({ maia }) => {

{percent > 0 - ? `${Math.round((MAIA_DOWNLOAD_MB * percent) / 100)} of ${MAIA_DOWNLOAD_MB} MB${timeLeft ? `, ${timeLeft}` : ''}` + ? `${Math.round((MAIA_DOWNLOAD_MB * percent) / 100)} of ${MAIA_DOWNLOAD_MB}MB${timeLeft ? `, ${timeLeft}` : ''}` : 'Starting download...'}

= ({ >
- + auto_awesome -

- Download Maia-3 +

+ Download the new Maia-3{' '} + (one-time {MAIA_DOWNLOAD_MB}MB download)

-

- See which moves people at each rating are likely to play. Maia-3 - runs on your device, so it's a one-time {MAIA_DOWNLOAD_MB} MB - download. -

-
{isDownloading || progress > 0 ? (
@@ -128,12 +123,12 @@ export const DownloadModelModal: React.FC = ({
) : null} -
+
{onNotNow ? ( @@ -152,7 +147,7 @@ export const DownloadModelModal: React.FC = ({
= 100 ? 'cursor-not-allowed border border-human-4/30 bg-human-4/50' : 'cursor-pointer border border-glass-border bg-human-4 hover:bg-human-3' @@ -169,15 +164,16 @@ export const DownloadModelModal: React.FC = ({ progress_activity ) : ( - - download + + + download + )}

- {isDownloading ? 'Downloading...' : 'Download'}{' '} - - ({MAIA_DOWNLOAD_MB} MB) - + {isDownloading + ? 'Downloading...' + : `Download Maia-3 (${MAIA_DOWNLOAD_MB}MB)`}

diff --git a/src/components/Common/MaiaDownloadPrompt.test.tsx b/src/components/Common/MaiaDownloadPrompt.test.tsx index dd241c1b..2b99d99b 100644 --- a/src/components/Common/MaiaDownloadPrompt.test.tsx +++ b/src/components/Common/MaiaDownloadPrompt.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MaiaDownloadPrompt } from './MaiaDownloadPrompt' import { trackDownloadModelDismissed } from 'src/lib/analytics' import { @@ -87,7 +87,10 @@ describe('MaiaDownloadPrompt', () => { fireEvent.click(screen.getByRole('button', { name: 'Not now' })) }) expect(trackDownloadModelDismissed).toHaveBeenCalledTimes(1) - expect(screen.queryByTestId('download-modal')).not.toBeInTheDocument() + // It fades out first. + await waitFor(() => + expect(screen.queryByTestId('download-modal')).not.toBeInTheDocument(), + ) const again = setup('no-cache', { withoutAsking: false }) expect( From 2ef3e85d0e39205fc8056aa893d18f4b823ba995 Mon Sep 17 00:00:00 2001 From: ashtonanderson Date: Thu, 5 Nov 2026 08:50:33 +0000 Subject: [PATCH 4/4] feat(maia): say why the download is worth it Co-Authored-By: Claude Opus 5.5 --- LOADING.md | 2 +- src/components/Common/DownloadModelModal.tsx | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/LOADING.md b/LOADING.md index 59a355c3..ccbc3d42 100644 --- a/LOADING.md +++ b/LOADING.md @@ -1498,7 +1498,7 @@ Stockfish doesn't wait for Maia, so nothing about the page needs the prompt to b | what changed | before | after | | ------------------------ | ----------------------------------- | --------------------------------------------------------------------------- | | desktop | full-page prompt, needs a click | page opens, Maia downloads by itself, small card shows progress | -| phone (or save-data, 2g) | full-page prompt, only exit is home | same size prompt, one line of text, "Not now" opens the page | +| phone (or save-data, 2g) | full-page prompt, only exit is home | same size prompt, shorter text, "Not now" opens the page | | after "Not now" | - | button in the empty Maia panel, and the prompt again in a new tab | | puzzles | prompt on arrival | nothing until the puzzle is over | | drills | prompt on arrival | choosing a drill isn't blocked; Maia is needed to start one | diff --git a/src/components/Common/DownloadModelModal.tsx b/src/components/Common/DownloadModelModal.tsx index 9dfe72c7..351253dd 100644 --- a/src/components/Common/DownloadModelModal.tsx +++ b/src/components/Common/DownloadModelModal.tsx @@ -116,6 +116,12 @@ export const DownloadModelModal: React.FC = ({
+

+ Our best model yet: the most human-like chess AI, now running on + your device, with human moves at every rating in under half a + second. +

+
{isDownloading || progress > 0 ? (