Skip to content
29 changes: 29 additions & 0 deletions apps/web/src/domains/chat/chat-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { useConversationListStore } from "@/domains/conversations/conversation-l

import { OfflineBanner } from "@/components/offline-banner.js";
import { AssistantSideMenu } from "@/domains/chat/components/assistant-side-menu.js";
import {
UpdateAvailableSidebarEntry,
useIsUpdateBannerVisible,
} from "@/domains/chat/components/update-available-sidebar-entry.js";
import { PreferencesMenu } from "@/domains/chat/components/preferences-menu.js";
import { useAssistantIdentityStore } from "@/stores/assistant-identity-store.js";
import { ChatLayoutHeader } from "./chat-layout-header.js";
Expand Down Expand Up @@ -334,6 +338,29 @@ export function ChatLayout() {

const isLibraryActive = location.pathname.startsWith("/assistant/library");

const isUpdateBannerVisible = useIsUpdateBannerVisible(
lifecycle.assistantId ?? null,
);
const [updateBannerDismissed, setUpdateBannerDismissed] = useState(false);

useEffect(() => {
setUpdateBannerDismissed(false);
}, [lifecycle.assistantId]);

const handleUpdateBannerVisibility = useCallback((visible: boolean) => {
if (!visible) {
setUpdateBannerDismissed(true);
}
}, []);

const updateBanner =
lifecycle.assistantId && isUpdateBannerVisible && !updateBannerDismissed ? (
<UpdateAvailableSidebarEntry
assistantId={lifecycle.assistantId}
onVisibilityChange={handleUpdateBannerVisibility}
/>
) : undefined;

const renderSideMenu = useCallback(
(args: SideMenuRenderArgs): ReactNode => (
<AssistantSideMenu
Expand All @@ -352,6 +379,7 @@ export function ChatLayout() {
onOpenIntelligence={handleOpenHome}
isLibraryActive={isLibraryActive}
onOpenLibrary={handleOpenLibrary}
footerBanner={updateBanner}
footerAction={
<PreferencesMenu
assistantId={lifecycle.assistantId}
Expand All @@ -377,6 +405,7 @@ export function ChatLayout() {
handleOpenHome,
isLibraryActive,
handleOpenLibrary,
updateBanner,
],
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
/**
* Sidebar footer banner prompting the user to upgrade when a newer assistant
* release is available. Shown inside `AssistantSideMenu` via the `footerBanner`
* prop, positioned above the preferences menu.
*
* Fetches the release list and compares against the assistant's current version
* using the shared semver utilities. Dismissal is persisted per-version in
* localStorage so the banner reappears only when a newer release ships.
*/

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { Button } from "@vellum/design-library";
import { toast } from "@vellum/design-library/components/toast";
import {
assistantsRetrieveOptions,
assistantsRetrieveQueryKey,
releasesListOptions,
} from "@/generated/api/@tanstack/react-query.gen.js";
import { assistantsUpgradeDetailCreate } from "@/generated/api/sdk.gen.js";
import { compareParsed, parseSemver } from "@/lib/semver/semver.js";
import { useAssistantAvatar } from "@/domains/avatar/use-assistant-avatar.js";
import { AvatarRenderer } from "@/components/avatar-renderer.js";

const DISMISS_STORAGE_KEY = "updateBannerDismissed";
const POLL_INTERVAL_MS = 3000;
const POLL_TIMEOUT_MS = 60_000;

interface DismissRecord {
version: string;
dismissedAt: number;
}

function readDismissRecord(): DismissRecord | null {
try {
const raw = window.localStorage.getItem(DISMISS_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as DismissRecord;
} catch {
return null;
}
}

function writeDismissRecord(version: string): void {
try {
const record: DismissRecord = { version, dismissedAt: Date.now() };
window.localStorage.setItem(DISMISS_STORAGE_KEY, JSON.stringify(record));
} catch {
// Storage unavailable
}
}

interface UpdateAvailableSidebarEntryProps {
assistantId: string;
onVisibilityChange?: (visible: boolean) => void;
}

export function useIsUpdateBannerVisible(assistantId: string | null): boolean {
const { data: assistant } = useQuery({
...assistantsRetrieveOptions({ path: { id: assistantId ?? "" } }),
enabled: !!assistantId,
});

const { data: releases } = useQuery(
releasesListOptions({ query: { stable: true } }),
);

const currentVersion = assistant?.current_release_version ?? null;
const latestRelease =
releases?.find((r) => r.is_stable !== false) ?? releases?.[0];
const latestVersion = latestRelease?.version ?? null;

return useMemo(() => {
if (!latestVersion || !currentVersion) return false;
const latest = parseSemver(latestVersion);
const current = parseSemver(currentVersion);
if (!latest || !current) return latestVersion !== currentVersion;
const upgradeAvailable = compareParsed(latest, current) > 0;
if (!upgradeAvailable) return false;
const record = readDismissRecord();
return record?.version !== latestVersion;
}, [latestVersion, currentVersion]);
}

export function UpdateAvailableSidebarEntry({
assistantId,
onVisibilityChange,
}: UpdateAvailableSidebarEntryProps) {
const queryClient = useQueryClient();
const [dismissed, setDismissed] = useState(false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 dismissed state is never reset when latestVersion changes, hiding banner for genuinely new releases

The dismissed state at update-available-sidebar-entry.tsx:62 is a simple boolean initialized to false and set to true in handleDismiss (update-available-sidebar-entry.tsx:160), but it is never reset back to false when latestVersion changes. The guard at line 163 checks !upgradeAvailable || dismissed || isDismissedForVersion — so once dismissed is true, it permanently suppresses the banner for the lifetime of the component, even if a newer release ships.

Scenario: user dismisses the v2.0.0 banner → dismissed = true, localStorage records "2.0.0". Later, v3.0.0 is released and React Query refetches. isDismissedForVersion correctly recalculates to false (localStorage has "2.0.0" ≠ "3.0.0"), but dismissed is still true, so the component returns null. The banner for v3.0.0 won't appear until the component remounts (page reload or navigation).

Suggested change
const [dismissed, setDismissed] = useState(false);
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 45d39b5. Added useEffect(() => setDismissed(false), [latestVersion]) so the in-memory dismiss state resets when a new version arrives. Combined with the isDismissedForVersion check (which reads the versioned localStorage record), a newer release will correctly re-show the banner.

const [isPollingUpgrade, setIsPollingUpgrade] = useState(false);
const targetVersionRef = useRef<string | null>(null);
const pollStartedAtRef = useRef<number>(0);

const { data: assistant } = useQuery(
assistantsRetrieveOptions({ path: { id: assistantId } }),
);

const pollRefetchInterval = (version: string | null | undefined) => {
if (
version &&
targetVersionRef.current &&
version === targetVersionRef.current
) {
queueMicrotask(() => {
setIsPollingUpgrade(false);
targetVersionRef.current = null;
pollStartedAtRef.current = 0;
toast.success("Update complete — assistant is healthy.");
});
return false as const;
}
if (Date.now() - pollStartedAtRef.current > POLL_TIMEOUT_MS) {
queueMicrotask(() => {
setIsPollingUpgrade(false);
targetVersionRef.current = null;
pollStartedAtRef.current = 0;
toast.error("Update is taking longer than expected. Please check Settings.");
});
return false as const;
}
return POLL_INTERVAL_MS;
};

useQuery({
...assistantsRetrieveOptions({ path: { id: assistantId } }),
refetchInterval: isPollingUpgrade
? (query) =>
pollRefetchInterval(query.state.data?.current_release_version)
: false,
});
Comment on lines +127 to +133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 No timeout on upgrade polling — indefinite refetch if backend upgrade silently fails

The polling at update-available-sidebar-entry.tsx:86-92 refetches every 3 seconds while isPollingUpgrade is true, stopping only when the assistant's current_release_version matches targetVersionRef.current. If the backend upgrade fails silently (no error thrown, version never changes), polling continues indefinitely. Consider adding a max poll count or timeout (e.g., 60 seconds) to set isPollingUpgrade = false and show an error toast. The X dismiss button is still clickable during upgrade and will hide the banner (but hooks continue running), so the user has a manual escape hatch, but the stale polling query would persist until unmount.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 45d39b5. Added a 60-second timeout (POLL_TIMEOUT_MS). If the version hasn't updated within that window, polling stops and a toast notifies the user: "Update is taking longer than expected. Please check Settings." The pollStartedAtRef is set when polling begins and checked each interval cycle.


const currentVersion = assistant?.current_release_version ?? null;

const { data: releases } = useQuery(
releasesListOptions({ query: { stable: true } }),
);

const latestRelease =
releases?.find((r) => r.is_stable !== false) ?? releases?.[0];
const latestVersion = latestRelease?.version ?? null;

const upgradeAvailable = useMemo(() => {
if (!latestVersion || !currentVersion) return false;
const latest = parseSemver(latestVersion);
const current = parseSemver(currentVersion);
if (!latest || !current) return latestVersion !== currentVersion;
return compareParsed(latest, current) > 0;
}, [latestVersion, currentVersion]);

const isDismissedForVersion = useMemo(() => {
if (!latestVersion) return false;
const record = readDismissRecord();
return record?.version === latestVersion;
}, [latestVersion]);

useEffect(() => {
setDismissed(false);
}, [latestVersion]);

const avatar = useAssistantAvatar(assistantId);

const upgradeMutation = useMutation({
mutationFn: async () => {
const { data } = await assistantsUpgradeDetailCreate({
path: { id: assistantId },
body: {},
throwOnError: true,
});
return data;
},
});

const handleUpgradeNow = useCallback(async () => {
try {
const result = await upgradeMutation.mutateAsync();
const isNoOp = result.detail?.includes("Already on the latest");
if (isNoOp) {
toast.success(result.detail);
return;
}
targetVersionRef.current =
result.version ?? latestVersion ?? null;
toast.success(
result.detail ??
`Update to ${result.version ?? latestVersion ?? "latest"} initiated.`,
);
pollStartedAtRef.current = Date.now();
setIsPollingUpgrade(true);
queryClient.invalidateQueries({
queryKey: assistantsRetrieveQueryKey({
path: { id: assistantId },
}),
});
} catch {
toast.error("Failed to trigger update. Please try again.");
}
}, [upgradeMutation, latestVersion, assistantId, queryClient]);

const handleDismiss = useCallback(() => {
if (latestVersion) {
writeDismissRecord(latestVersion);
}
setDismissed(true);
onVisibilityChange?.(false);
}, [latestVersion, onVisibilityChange]);

const isVisible = upgradeAvailable && !dismissed && !isDismissedForVersion;

useEffect(() => {
onVisibilityChange?.(isVisible);
}, [isVisible, onVisibilityChange]);

if (!isVisible) {
return null;
}

const isUpgrading = upgradeMutation.isPending || isPollingUpgrade;

return (
<div
data-slot="update-available-sidebar-entry"
className="flex flex-col gap-2 overflow-hidden rounded-lg border px-3 py-3"
style={{
background: "var(--surface-overlay)",
borderColor: "var(--border-base)",
animation: "fadeInUp 0.25s ease-out both",
}}
>
<div className="flex items-center gap-3">
{avatar.components ? (
<AvatarRenderer
components={avatar.components}
bodyShapeId={avatar.traits?.bodyShape ?? "default"}
eyeStyleId={avatar.traits?.eyeStyle ?? "default"}
colorId={avatar.traits?.color ?? "default"}
size={32}
className="shrink-0"
/>
) : avatar.customImageUrl ? (
<img
src={avatar.customImageUrl}
alt="Assistant avatar"
className="size-8 shrink-0 rounded-full object-cover"
/>
) : (
<div
className="size-8 shrink-0 rounded-full"
style={{ background: "var(--surface-active)" }}
/>
)}

<p
className="min-w-0 flex-1 truncate text-body-small-default leading-tight"
style={{ color: "var(--content-default)" }}
title={`New version — ${latestVersion}`}
>
New version — {latestVersion}
</p>

<button
type="button"
className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md transition-opacity hover:opacity-70"
style={{ color: "var(--content-tertiary)" }}
onClick={handleDismiss}
aria-label="Dismiss update banner"
>
<X size={12} aria-hidden />
</button>
</div>

<div className="flex min-w-0 gap-2">
<Button
variant="primary"
size="compact"
className="min-w-0 flex-1"
onClick={() => void handleUpgradeNow()}
disabled={isUpgrading}
leftIcon={
isUpgrading ? (
<Loader2 className="animate-spin" />
) : undefined
}
>
{isUpgrading ? "Updating…" : "Upgrade now"}
</Button>
<Button
variant="outlined"
size="compact"
className="min-w-0 flex-1"
onClick={handleDismiss}
disabled={isUpgrading}
>
Upgrade later
</Button>
</div>
</div>
);
}
Loading
Loading