Studio: show the llama.cpp update banner sooner and keep it until dismissed (#6162)

Follow-up to #6097. The update banner appeared 8s after load and auto-hid after
about 10s. Show it about 1s after a newer prebuilt is detected and keep it up
until the user dismisses it (click outside or the X) or runs the update; it
stays during an in-progress update so the progress is visible.

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
This commit is contained in:
Daniel Han 2026-06-10 10:48:44 -07:00 committed by GitHub
commit aa3b46f4c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 59 additions and 50 deletions

View file

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { toast } from "@/lib/toast";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement } from "react";
import { type ReactElement, useEffect, useRef } from "react";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
@ -14,9 +14,9 @@ interface LlamaUpdateBannerProps {
}
/**
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right when a newer
* prebuilt is available, fades on its own after ~10s, and re-surfaces hourly.
* Clicking Update swaps the prebuilt in place via POST /api/llama/update.
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a
* newer prebuilt is detected and stays up until dismissed (click outside / X)
* or updated. Clicking Update swaps the prebuilt in place via POST /api/llama/update.
*/
export function LlamaUpdateBanner({
enabled = true,
@ -32,16 +32,38 @@ export function LlamaUpdateBanner({
`llama.cpp updated to ${result.tag ?? "the latest build"}. Reload your model to use it.`,
);
} else if (result) {
toast.error(`llama.cpp update failed: ${result.error ?? "unknown error"}`);
toast.error(
`llama.cpp update failed: ${result.error ?? "unknown error"}`,
);
}
}
const show = visible && status != null && (status.update_available || applying);
const show =
visible && status != null && (status.update_available || applying);
const bannerRef = useRef<HTMLDivElement>(null);
// Dismiss when the user clicks anything outside the banner. Kept off while an
// update is applying so the progress stays visible.
useEffect(() => {
if (!show || applying) return;
function onPointerDown(event: PointerEvent) {
if (
bannerRef.current &&
!bannerRef.current.contains(event.target as Node)
) {
dismiss();
}
}
document.addEventListener("pointerdown", onPointerDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
}, [show, applying, dismiss]);
return (
<AnimatePresence>
{show ? (
<motion.div
ref={bannerRef}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
@ -50,7 +72,7 @@ export function LlamaUpdateBanner({
data-testid="llama-update-banner"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
{!applying ? (
{applying ? null : (
<button
type="button"
onClick={dismiss}
@ -73,7 +95,7 @@ export function LlamaUpdateBanner({
/>
</svg>
</button>
) : null}
)}
<div className="flex items-center gap-2 pr-5">
<span className="text-base" aria-hidden="true">

View file

@ -4,12 +4,10 @@
import { authFetch, getAuthToken } from "@/features/auth";
import { useCallback, useEffect, useRef, useState } from "react";
// First check shortly after load, then re-surface as an hourly reminder.
const FIRST_CHECK_DELAY_MS = 8000;
// First check shortly after load, then re-surface as an hourly reminder. The
// banner stays up until the user dismisses it (click outside / X) or updates.
const FIRST_CHECK_DELAY_MS = 1000;
const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour
// The banner fades on its own after this long (non-invasive). It re-appears on
// the next hourly reminder while an update is still available.
const AUTO_HIDE_MS = 10000;
// While an update is applying, poll the job state at this cadence.
const JOB_POLL_INTERVAL_MS = 3000;
@ -48,7 +46,9 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
};
}
async function fetchStatus(forceRefresh = false): Promise<LlamaUpdateStatus | null> {
async function fetchStatus(
forceRefresh = false,
): Promise<LlamaUpdateStatus | null> {
if (!getAuthToken()) return null;
try {
const res = await authFetch(
@ -73,28 +73,18 @@ export interface LlamaApplyResult {
/**
* Polls the backend for a newer llama.cpp prebuilt. When one exists, `visible`
* is true for a 10s window (fades on its own), and re-surfaces every ~hour as a
* gentle reminder. `apply()` triggers the in-place swap and tracks the job.
* becomes true ~1s after load and stays up until the user dismisses it (click
* outside / X) or updates; it re-surfaces every ~hour as a reminder. `apply()`
* triggers the in-place swap and tracks the job.
*/
export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptions = {}) {
export function useLlamaUpdateCheck({
enabled = true,
}: UseLlamaUpdateCheckOptions = {}) {
const [status, setStatus] = useState<LlamaUpdateStatus | null>(null);
const [visible, setVisible] = useState(false);
const [applying, setApplying] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const clearHideTimer = useCallback(() => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}, []);
const armAutoHide = useCallback(() => {
clearHideTimer();
hideTimer.current = setTimeout(() => setVisible(false), AUTO_HIDE_MS);
}, [clearHideTimer]);
const clearPollTimer = useCallback(() => {
if (pollTimer.current) {
clearInterval(pollTimer.current);
@ -118,20 +108,19 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
setVisible(false);
onDone?.({ ok: true, tag: s.job.to_tag });
} else if (s.job.state === "error") {
armAutoHide();
// Leave the banner up so the user can retry; clearing applying drops
// the "Updating..." state.
onDone?.({ ok: false, error: s.job.error });
} else {
// idle without a terminal result (job reset): stop so the banner
// does not stick on "Updating...".
armAutoHide();
// idle without a terminal result (job reset): stop tracking.
onDone?.({ ok: false, error: "update did not complete" });
}
}, JOB_POLL_INTERVAL_MS);
},
[armAutoHide, clearPollTimer],
[clearPollTimer],
);
// Surface the banner for the auto-hide window when an update is available.
// Surface the banner when an update is available; it stays up until dismissed.
const surfaceIfAvailable = useCallback(
(next: LlamaUpdateStatus | null) => {
if (!next) return;
@ -141,16 +130,14 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
// job so "Updating..." clears when it finishes instead of sticking.
setApplying(true);
setVisible(true);
clearHideTimer();
if (!pollTimer.current) startJobPoll();
return;
}
if (next.update_available) {
setVisible(true);
armAutoHide();
}
},
[armAutoHide, clearHideTimer, startJobPoll],
[startJobPoll],
);
useEffect(() => {
@ -173,21 +160,18 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
canceled = true;
clearTimeout(firstTimer);
clearInterval(reminder);
clearHideTimer();
clearPollTimer();
};
}, [enabled, surfaceIfAvailable, clearHideTimer, clearPollTimer]);
}, [enabled, surfaceIfAvailable, clearPollTimer]);
const dismiss = useCallback(() => {
clearHideTimer();
setVisible(false);
}, [clearHideTimer]);
}, []);
const apply = useCallback(async (): Promise<LlamaApplyResult> => {
if (applying) return { ok: false, error: "already running" };
setApplying(true);
setVisible(true);
clearHideTimer();
let action: {
started?: boolean;
reason?: string | null;
@ -197,7 +181,6 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
const res = await authFetch("/api/llama/update", { method: "POST" });
if (!res.ok) {
setApplying(false);
armAutoHide();
return { ok: false, error: `HTTP ${res.status}` };
}
try {
@ -207,24 +190,28 @@ export function useLlamaUpdateCheck({ enabled = true }: UseLlamaUpdateCheckOptio
}
} catch (e) {
setApplying(false);
armAutoHide();
return { ok: false, error: String(e) };
}
// 200 without a started job (no marker / installer missing) leaves it idle,
// so surface the reason instead of polling forever. already_running is the
// exception: a job is in flight, so track it to completion below.
if (action && action.started === false && action.reason !== "already_running") {
if (
action &&
action.started === false &&
action.reason !== "already_running"
) {
setApplying(false);
armAutoHide();
return {
ok: false,
error: action.message ?? action.reason ?? "update was not started",
};
}
return await new Promise<LlamaApplyResult>((resolve) => startJobPoll(resolve));
}, [applying, armAutoHide, clearHideTimer, startJobPoll]);
return await new Promise<LlamaApplyResult>((resolve) =>
startJobPoll(resolve),
);
}, [applying, startJobPoll]);
return {
status: enabled ? status : null,