diff --git a/AGENTS.md b/AGENTS.md index 05238338a22cf..f71d37cf2be39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,7 @@ NeoCode is a distinct product built from upstream T3 Code (`pingdotgg/t3code`), | `apps/web/src/components/{desktop/DesktopAppActivationCoordinator,desktop/SshPasswordPromptDialog,onboarding/FirstRunGate,onboarding/WelcomeWizard,preview/PreviewPanel,cloud/RelayClientInstallDialog}.tsx`, `apps/web/src/components/{desktopUpdate.logic,desktopUpdate.logic.test}.ts` | NeoCode onboarding, connection, preview and update guidance; show NeoPi readiness first without changing enabled choices or initiating authentication; retain activation, recovery and update behavior | | `apps/web/src/components/clerk/{MobileClientsUserProfilePage.logic,MobileClientsUserProfilePage.logic.test}.ts`, `apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx`, `apps/web/src/connection/{clientMetadata,clientMetadata.test}.ts`, `apps/web/src/{cloud/linkEnvironment,desktopAppActivation}.ts` | NeoCode client and authorization display metadata; retain protocol fields, connection behavior and metadata assertions | | `apps/web/src/components/settings/{BrowserImportWizard,CaptureShortcutConfig,ConnectionsSettings,IntegrationsSettings,KeybindingsSettings,LocalEnvironmentSetting,NotificationSettings,SettingsPanels,SnapShotSettings,SnapShotSetupDialog,ThemeImportDialog,ThemePreviewCircles}.tsx`, `apps/web/src/components/settings/providerStatus.ts`, `apps/web/src/routes/{_chat.index,_chat.pull-requests,_chat}.tsx` | Reuse web branding in settings, navigation, capture and provider guidance without changing entry points or saved choices | +| `apps/web/src/components/settings/FontFamilyPicker.tsx` | Classify installed monospace fonts only for an opened picker, in cancellable batches, instead of blocking Appearance on mount; the cache and scheduler live in fork-owned `installedMonospaceFonts.ts` | | `packages/client-runtime/src/connection/compatibility.ts`, `packages/client-runtime/src/state/{pullRequestDiffHttp,pullRequestDiffHttp.test}.ts`, `packages/contracts/src/{browserImport,previewAutomation,pullRequest}.ts` | Neutral cross-client compatibility, rejected-session recovery and permission guidance; preserve schemas and protocol identities | | `README.md`, `docs/user/install.md` | Identify NeoCode and upstream attribution; distinguish fork releases from upstream installers; explain NeoPi executable selection, retained capability diagnostics, per-instance discovery failures and Linux URL registration | | `docs/operations/development.md` | Distinguish rebuilt source output from installed artifacts; document complete Linux source URL commands and their inherited-environment handling | diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index b7e43eef82092..344ba3b2647da 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -1,7 +1,8 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { CheckIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; +import { queryInstalledFontFamilies } from "../../appearanceFonts"; +import { filterInstalledMonospaceFonts } from "../../installedMonospaceFonts"; import { Combobox, ComboboxEmpty, @@ -14,6 +15,7 @@ import { import { SelectButton } from "../ui/select"; const DEFAULT_FONT_VALUE = "__default__"; +const EMPTY_FONT_FAMILIES: readonly string[] = []; function supportsFontEnumeration(): boolean { return ( @@ -139,10 +141,35 @@ export function FontFamilyPicker({ if (nextOpen) setQuery(""); }; - const families = useMemo(() => { - if (enumeration.status !== "granted") return []; - return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families; - }, [enumeration, requireMonospace]); + const [monospaceResult, setMonospaceResult] = useState<{ + source: readonly string[]; + families: readonly string[]; + } | null>(null); + useEffect(() => { + // A closed picker must not measure every installed face during the + // Appearance mount. Stop scanning when it closes or the page unmounts. + if (!open || !requireMonospace || enumeration.status !== "granted") return; + const source = enumeration.families; + if (monospaceResult?.source === source) return; + const controller = new AbortController(); + void filterInstalledMonospaceFonts(source, controller.signal).then((families) => { + if (!controller.signal.aborted) setMonospaceResult({ source, families }); + }); + return () => controller.abort(); + }, [open, requireMonospace, enumeration, monospaceResult]); + + const loadingMonospace = + requireMonospace && + enumeration.status === "granted" && + monospaceResult?.source !== enumeration.families; + const families = + enumeration.status !== "granted" + ? EMPTY_FONT_FAMILIES + : requireMonospace + ? monospaceResult?.source === enumeration.families + ? monospaceResult.families + : EMPTY_FONT_FAMILIES + : enumeration.families; const items = useMemo(() => { const trimmedQuery = query.trim().toLowerCase(); @@ -212,7 +239,13 @@ export function FontFamilyPicker({ onChange={(event) => setQuery(event.target.value)} />
- No fonts found. + {loadingMonospace ? ( +
+ Finding monospace fonts… +
+ ) : ( + No fonts found. + )}
diff --git a/apps/web/src/installedMonospaceFonts.test.ts b/apps/web/src/installedMonospaceFonts.test.ts new file mode 100644 index 0000000000000..ef6ceca603f5f --- /dev/null +++ b/apps/web/src/installedMonospaceFonts.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { isMonospaceFamily } from "./appearanceFonts"; + +vi.mock("./appearanceFonts", () => ({ isMonospaceFamily: vi.fn() })); + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + vi.mocked(isMonospaceFamily).mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("installed monospace font discovery", () => { + it("lets other UI work run between font measurements and preserves family order", async () => { + const { filterInstalledMonospaceFonts } = await import("./installedMonospaceFonts"); + let now = 0; + vi.spyOn(performance, "now").mockImplementation(() => now); + vi.mocked(isMonospaceFamily).mockImplementation((family) => { + now += 10; + return family !== "Proportional"; + }); + const result = filterInstalledMonospaceFonts( + ["First Mono", "Proportional", "Last Mono"], + new AbortController().signal, + ); + expect(isMonospaceFamily).not.toHaveBeenCalled(); + + let measuredBeforeInput = 0; + setTimeout(() => { + measuredBeforeInput = vi.mocked(isMonospaceFamily).mock.calls.length; + }, 0); + await vi.runAllTimersAsync(); + + expect(measuredBeforeInput).toBe(1); + expect(await result).toEqual(["First Mono", "Last Mono"]); + }); + + it("stops probing when a picker closes and reuses completed measurements on reopen", async () => { + const { filterInstalledMonospaceFonts } = await import("./installedMonospaceFonts"); + const controller = new AbortController(); + let now = 0; + vi.spyOn(performance, "now").mockImplementation(() => now); + vi.mocked(isMonospaceFamily).mockImplementation(() => { + now += 10; + return true; + }); + const families = ["First Mono", "Second Mono", "Third Mono"]; + const cancelled = filterInstalledMonospaceFonts(families, controller.signal); + setTimeout(() => controller.abort(), 0); + await vi.runAllTimersAsync(); + await cancelled; + expect(isMonospaceFamily).toHaveBeenCalledTimes(1); + + const reopened = filterInstalledMonospaceFonts(families, new AbortController().signal); + await vi.runAllTimersAsync(); + expect(await reopened).toEqual(families); + expect(isMonospaceFamily).toHaveBeenCalledTimes(3); + + const remounted = filterInstalledMonospaceFonts(families, new AbortController().signal); + await vi.runAllTimersAsync(); + expect(await remounted).toEqual(families); + expect(isMonospaceFamily).toHaveBeenCalledTimes(3); + }); + + it("does not measure anything after unmounting before the first scan", async () => { + const { filterInstalledMonospaceFonts } = await import("./installedMonospaceFonts"); + const controller = new AbortController(); + const result = filterInstalledMonospaceFonts(["Unvisited Font"], controller.signal); + controller.abort(); + await vi.runAllTimersAsync(); + expect(await result).toEqual([]); + expect(isMonospaceFamily).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/installedMonospaceFonts.ts b/apps/web/src/installedMonospaceFonts.ts new file mode 100644 index 0000000000000..a993504d92405 --- /dev/null +++ b/apps/web/src/installedMonospaceFonts.ts @@ -0,0 +1,40 @@ +import { isMonospaceFamily } from "./appearanceFonts"; + +// Installed families do not change during enumeration. Reuse measurements +// across the code and terminal pickers and across Appearance remounts. +const monospaceByFamily = new Map(); + +/** Probe an opened picker without monopolizing the UI thread. */ +export function filterInstalledMonospaceFonts( + families: readonly string[], + signal: AbortSignal, +): Promise { + return new Promise((resolve) => { + const matches: string[] = []; + let index = 0; + const scan = () => { + if (signal.aborted) { + resolve([]); + return; + } + const deadline = performance.now() + 8; + while (index < families.length) { + const family = families[index++]!; + let monospace = monospaceByFamily.get(family); + if (monospace === undefined) { + monospace = isMonospaceFamily(family); + monospaceByFamily.set(family, monospace); + } + if (monospace) matches.push(family); + if (performance.now() >= deadline) break; + } + if (index === families.length) { + resolve(matches); + } else { + setTimeout(scan, 0); + } + }; + // Yield before the first measurement too, so opening paints immediately. + setTimeout(scan, 0); + }); +}