diff --git a/frontend/src/components/AppUpdateGate.tsx b/frontend/src/components/AppUpdateGate.tsx index 78d7c6f28..c2f3e9d09 100644 --- a/frontend/src/components/AppUpdateGate.tsx +++ b/frontend/src/components/AppUpdateGate.tsx @@ -10,6 +10,7 @@ import { import { AppUpdate, System } from "@/lib/rpc" import { useProjects } from "@/providers/projects" import { queuePaste } from "@/lib/terminal/paste-queue" +import { readPref, writePref } from "@/lib/prefs" import { registerUpdateChecker } from "@/lib/update/update-check" import { errorText } from "@/lib/utils" @@ -42,11 +43,6 @@ export function AppUpdateGate() { return () => clearInterval(id) }, []) - useEffect(() => { - registerUpdateChecker(checkNow) - return () => registerUpdateChecker(null) - }) - const prompt = (action: UpdateAction & { kind: "update" }) => { promptedVersion.current = action.version if (action.canSelfApply) { @@ -60,7 +56,7 @@ export function AppUpdateGate() { let action: UpdateAction try { const status = await AppUpdate.Status() - action = decideUpdateAction(status, localStorage.getItem(UPDATE_DISMISSED_KEY)) + action = decideUpdateAction(status, readPref(UPDATE_DISMISSED_KEY)) } catch { return } @@ -79,7 +75,19 @@ export function AppUpdateGate() { return status } - const dismiss = (version: string) => localStorage.setItem(UPDATE_DISMISSED_KEY, version) + // The manual check closes over this render's state, so it rides a ref and the + // registration runs once. Declared here rather than beside the poll above: + // it reads checkNow, and an effect with no dependency array — what this was — + // re-registered on every render to the same effect. + const checkNowRef = useRef(checkNow) + checkNowRef.current = checkNow + + useEffect(() => { + registerUpdateChecker(() => checkNowRef.current()) + return () => registerUpdateChecker(null) + }, []) + + const dismiss = (version: string) => writePref(UPDATE_DISMISSED_KEY, version) // Windows/macOS: swap the binary in place, then offer a one-click restart. const promptSelfApply = (version: string) => { diff --git a/frontend/src/components/ClaudePluginGate.tsx b/frontend/src/components/ClaudePluginGate.tsx index f7aaa4010..f28c1051a 100644 --- a/frontend/src/components/ClaudePluginGate.tsx +++ b/frontend/src/components/ClaudePluginGate.tsx @@ -19,6 +19,7 @@ import { } from "@/lib/update/plugin-gate" import { ClaudePlugin } from "@/lib/rpc" import { errorText } from "@/lib/utils" +import { readPref, writePref } from "@/lib/prefs" import { runWithToast } from "@/lib/toast-async" // ClaudePluginGate checks on startup whether the lich Claude Code plugin is @@ -44,8 +45,8 @@ export function ClaudePluginGate() { const status = await ClaudePlugin.Status() action = decidePluginAction( status, - localStorage.getItem(INSTALL_DISMISSED_KEY) === DISMISSED_FLAG, - localStorage.getItem(UPDATE_DISMISSED_KEY), + readPref(INSTALL_DISMISSED_KEY) === DISMISSED_FLAG, + readPref(UPDATE_DISMISSED_KEY), ) } catch { return @@ -63,7 +64,7 @@ export function ClaudePluginGate() { action: { label: "Update", onClick: () => void runUpdate() }, cancel: { label: "Later", - onClick: () => localStorage.setItem(UPDATE_DISMISSED_KEY, version), + onClick: () => writePref(UPDATE_DISMISSED_KEY, version), }, }) } @@ -90,7 +91,7 @@ export function ClaudePluginGate() { } const dismissForever = () => { - localStorage.setItem(INSTALL_DISMISSED_KEY, DISMISSED_FLAG) + writePref(INSTALL_DISMISSED_KEY, DISMISSED_FLAG) setInstallOpen(false) } diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx index 317f1afe4..fc39e7043 100644 --- a/frontend/src/components/FooterBar.tsx +++ b/frontend/src/components/FooterBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useEffect, useState, type ReactNode } from "react" import { useNavigate } from "react-router-dom" import { openPulls } from "@/lib/pulls-card-store" import { toast } from "sonner" @@ -32,6 +32,48 @@ function useNow(): Date { const two = (n: number): string => String(n).padStart(2, "0") +interface FooterButtonProps { + /** Both the tooltip and the accessible name — one string, one meaning. */ + label: string + onClick: () => void + /** Set for a button that toggles a dock panel: it reads as pressed while + * that panel is open. Left off for a plain action. */ + pressed?: boolean + disabled?: boolean + /** Roomier padding for a button carrying text beside its glyph. */ + wide?: boolean + children: ReactNode +} + +// One segment of the status strip: a glyph (sometimes with a readout beside +// it), its meaning in a tooltip, and the accent fill that marks the dock panel +// it opens as the one on screen. +function FooterButton({ label, onClick, pressed, disabled, wide, children }: FooterButtonProps) { + return ( + + + } + > + {children} + + {label} + + ) +} + interface FooterBarProps { dock: DockTab | null onDock: (tab: DockTab) => void @@ -104,91 +146,51 @@ export function FooterBar({ dock, onDock }: FooterBarProps) { return (