fix: honor automatic updates preference and add Settings controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user