fix: honor automatic updates preference and add Settings controls

This commit is contained in:
amosblomqvist
2026-10-25 19:19:38 +02:00
parent b277ee3e0f
commit 769fc85e17
14 changed files with 820 additions and 75 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ export function SettingsRow({
dimmed,
}: {
label: string;
hint?: string;
hint?: ReactNode;
children: React.ReactNode;
testid?: string;
/** Dependency graying, graph-view style: the whole row dims when the
+9 -18
View File
@@ -40,7 +40,8 @@ import {
type UserSettings,
} from "@/src/core";
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { SettingsSection } from "./controls.tsx";
import { UpdateSettings } from "./update-settings.tsx";
import { AgentSection } from "./agent-section.tsx";
import type { ModelsSettings } from "@/src/core";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell";
@@ -75,10 +76,6 @@ const DialogContext = createContext<{ open: (section?: string, row?: string) =>
/** AppShell-level mount: the sidebar row and the mod+, chord call this.
* `open(section)` lands the dialog on a specific section (the first-run
* setup flow deep-links to Keys when no provider key exists yet). */
/** The auto-update row's hint (tooltip wave): honest user language — no
* stream vocabulary. Exported for the copy pin (tooltip-mechanism test). */
export const AUTO_UPDATE_HINT = "Automatic updates arrive in an upcoming release";
export function SettingsDialogProvider({
children,
}: {
@@ -92,6 +89,7 @@ export function SettingsDialogProvider({
setLandOnRow(row ?? null);
setShow(true);
}, []);
useEffect(() => window.lh?.onOpenSettings?.((section) => open(section)), [open]);
return (
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
{children}
@@ -362,19 +360,12 @@ export function SettingsDialogBody({
title="General"
testid="section-general"
>
<SettingsRow
label="Auto-update"
hint={AUTO_UPDATE_HINT}
testid="row-auto-update"
>
<Toggle
checked={record.general?.autoUpdate ?? true}
label="auto update"
onChange={(on) =>
void write("general", { autoUpdate: on })
}
/>
</SettingsRow>
<UpdateSettings
autoUpdate={record.general?.autoUpdate ?? true}
onAutoUpdateChange={(on) =>
void write("general", { ...record.general, autoUpdate: on })
}
/>
</SettingsSection>
)}
{active === "keys" && (
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { BrailleSpinner } from "@/components/braille-spinner";
import { SettingsRow, Toggle } from "./controls.tsx";
/** Observational snapshot from electron/main.cjs; never updater permission. */
export type UpdateState = {
status: "idle" | "checking" | "downloading" | "up-to-date" | "downloaded" | "error" | "disabled";
version: string | null;
revision: number;
};
export const APP_VERSION = process.env.LH_VERSION ?? "";
const HOMEPAGE = process.env.LH_HOMEPAGE ?? "";
export const MANUAL_UPDATE_HINT = "Checking downloads available updates. Downloaded updates install when you quit the app.";
export const AUTO_UPDATE_HINT = "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.";
const UPDATE_ERROR = "Couldn’t check for updates. Try again later.";
/** Subscribe before requesting the snapshot. IPC replies and pushes can
* cross; a revision prevents an older reply from hiding a newer event.
* The banner shares this read-only observer so late loads recover too. */
export function useUpdateState() {
const [state, setState] = useState<UpdateState | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const lh = window.lh;
if (!lh) return;
let live = true;
let revision = -1;
const accept = (next: UpdateState) => {
if (!live || next.revision < revision) return;
revision = next.revision;
setState(next);
setFailed(false);
};
const off = lh.onUpdateStatus?.(accept);
void lh.getUpdateState?.().then(accept).catch(() => {
if (live && revision === -1) setFailed(true);
});
return () => { live = false; off?.(); };
}, []);
return { state, failed };
}
export function updateStatusText(state: UpdateState | null): string | null {
switch (state?.status) {
case "checking": return "Checking for updates…";
case "downloading": return state.version ? `Downloading version ${state.version}…` : "Downloading an update…";
case "up-to-date": return "No newer version was found at the last check.";
case "downloaded": return `${state.version ? `Version ${state.version}` : "An update"} is ready. It will install when you quit the app.`;
case "error": return UPDATE_ERROR;
default: return null;
}
}
/** Version information is useful everywhere; update controls only belong
* to a shell with an available updater. Opening this row never checks. */
export function UpdateSettings({
autoUpdate,
onAutoUpdateChange,
version = APP_VERSION,
homepage = HOMEPAGE,
}: {
autoUpdate: boolean;
onAutoUpdateChange: (on: boolean) => void;
version?: string;
homepage?: string;
}) {
const { state, failed } = useUpdateState();
const [checkFailed, setCheckFailed] = useState(false);
useEffect(() => setCheckFailed(false), [state]);
const active = state !== null && state.status !== "disabled";
const busy = state?.status === "checking" || state?.status === "downloading";
const status = state?.status === "downloaded" ? updateStatusText(state)
: failed || checkFailed ? UPDATE_ERROR : updateStatusText(state);
const releaseNotes = version && homepage ? `${homepage}/releases/tag/v${version}` : null;
const check = () => {
setCheckFailed(false);
void window.lh?.checkForUpdates?.().catch(() => setCheckFailed(true));
};
return (
<>
<SettingsRow
label={`Version ${version || "unknown"}`}
testid="row-version"
hint={
<>
{status && <span className="block" role="status" data-testid="update-status">{status}</span>}
{active && <span className="block">{MANUAL_UPDATE_HINT}</span>}
{releaseNotes && <a href={releaseNotes} target="_blank" rel="noopener noreferrer" className="underline-offset-2 hover:text-foreground hover:underline" data-testid="release-notes">Release notes</a>}
</>
}
>
{active && (state.status === "downloaded" ? (
<Button type="button" size="xs" className="cursor-pointer" data-testid="update-restart" onClick={() => window.lh?.installUpdate?.()}>Restart</Button>
) : (
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" data-testid="update-check" disabled={busy} aria-busy={busy} onClick={check}>
{busy && <BrailleSpinner label={state.status === "downloading" ? "Downloading update" : "Checking for updates"} />}
Check for updates
</Button>
))}
</SettingsRow>
{active && (
<SettingsRow label="Automatic updates" hint={AUTO_UPDATE_HINT} testid="row-auto-update">
<Toggle checked={autoUpdate} label="auto update" onChange={onAutoUpdateChange} />
</SettingsRow>
)}
</>
);
}
+7 -2
View File
@@ -49,6 +49,7 @@ const STORAGE_KEY = "lh:sidebar-open";
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
*/
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
import type { UpdateState } from "@/components/settings/update-settings.tsx";
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
@@ -66,9 +67,13 @@ declare global {
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
getZoom?: () => Promise<number>;
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
/** Observational update events and a snapshot for late subscribers. */
onUpdateStatus?: (cb: (status: UpdateState) => void) => () => void;
getUpdateState?: () => Promise<UpdateState>;
/** Explicit manual check also downloads and stages available updates. */
checkForUpdates?: () => Promise<void>;
installUpdate?: () => void;
onOpenSettings?: (cb: (section: string) => void) => () => void;
};
}
}
+5 -18
View File
@@ -3,32 +3,19 @@
/**
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
* restart-banner UX). The shell pushes "lh:update-status" once a staged
* update exists (electron/main.cjs update-downloaded); this is the whole
* renderer surface — a small fixed card, bottom-right, offering Restart.
* update exists (electron/main.cjs update-downloaded). Snapshot retrieval
* also covers a late load. The card offers Restart alongside Settings.
* Browser builds have no window.lh and never see a push: renders null.
*
* No dismiss: a downloaded update installs on quit anyway
* (autoInstallOnAppQuit); the banner is a courtesy, not a gate.
*/
import { useEffect, useState } from "react";
// The window.lh bridge type lives in components/sidebar.tsx (the incumbent
// declare-global) — onUpdateStatus/installUpdate are declared there too.
type UpdateStatus = { status: string; version: string | null };
import { useUpdateState } from "@/components/settings/update-settings.tsx";
export default function UpdateBanner() {
const [ready, setReady] = useState<UpdateStatus | null>(null);
useEffect(() => {
// Absent bridge (browser / dev) → stays hidden forever.
return window.lh?.onUpdateStatus?.((s) => {
if (s?.status === "downloaded") setReady(s);
});
}, []);
if (!ready) return null;
const { state: ready } = useUpdateState();
if (ready?.status !== "downloaded") return null;
return (
<div
data-testid="update-banner"