From 290d9bfb4938198a8217d1d54397074ad12215af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:30:54 +0000 Subject: [PATCH 1/5] feat(web): add update-available banner to sidebar footer Show a banner in the sidebar footer when a newer assistant release is available. The banner displays the assistant avatar, the new version number, and Upgrade now / Upgrade later buttons. Dismissal is persisted per-version in localStorage so the banner reappears only for new releases. Wired into ChatLayout via the existing footerBanner prop on AssistantSideMenu. Co-Authored-By: Jason Zhou --- apps/web/src/domains/chat/chat-layout.tsx | 11 + .../update-available-sidebar-entry.tsx | 247 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx diff --git a/apps/web/src/domains/chat/chat-layout.tsx b/apps/web/src/domains/chat/chat-layout.tsx index 6a84dec5cbc..af1fe5e27c1 100644 --- a/apps/web/src/domains/chat/chat-layout.tsx +++ b/apps/web/src/domains/chat/chat-layout.tsx @@ -19,6 +19,7 @@ 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 } from "@/domains/chat/components/update-available-sidebar-entry.js"; import { ChatLayoutHeader } from "./chat-layout-header.js"; /** @@ -319,6 +320,14 @@ export function ChatLayout() { const isLibraryActive = location.pathname.startsWith("/assistant/library"); + const updateBanner = useMemo( + () => + lifecycle.assistantId ? ( + + ) : null, + [lifecycle.assistantId], + ); + const renderSideMenu = useCallback( (args: SideMenuRenderArgs): ReactNode => ( @@ -353,6 +363,7 @@ export function ChatLayout() { handleOpenHome, isLibraryActive, handleOpenLibrary, + updateBanner, ], ); diff --git a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx new file mode 100644 index 00000000000..58406427fb6 --- /dev/null +++ b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx @@ -0,0 +1,247 @@ +/** + * 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, 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; + +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; +} + +export function UpdateAvailableSidebarEntry({ + assistantId, +}: UpdateAvailableSidebarEntryProps) { + const queryClient = useQueryClient(); + const [dismissed, setDismissed] = useState(false); + const [isPollingUpgrade, setIsPollingUpgrade] = useState(false); + const targetVersionRef = useRef(null); + + 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; + toast.success("Update complete — assistant is healthy."); + }); + return false as const; + } + return POLL_INTERVAL_MS; + }; + + useQuery({ + ...assistantsRetrieveOptions({ path: { id: assistantId } }), + refetchInterval: isPollingUpgrade + ? (query) => + pollRefetchInterval(query.state.data?.current_release_version) + : false, + }); + + 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]); + + 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.`, + ); + 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); + }, [latestVersion]); + + if (!upgradeAvailable || dismissed || isDismissedForVersion) { + return null; + } + + const isUpgrading = upgradeMutation.isPending || isPollingUpgrade; + + return ( +
+ + +
+ {avatar.components ? ( + + ) : avatar.customImageUrl ? ( + Assistant avatar + ) : ( +
+ )} + +
+

+ New version — {latestVersion} +

+ +
+ + +
+
+
+
+ ); +} From 45d39b5fc347f9dc39da794dbf3a44ca4a05372c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:43:55 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20dismiss=20reset,=20footer=20visibility=20gate,=20po?= =?UTF-8?q?ll=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Jason Zhou --- apps/web/src/domains/chat/chat-layout.tsx | 32 +++++++--- .../update-available-sidebar-entry.tsx | 59 ++++++++++++++++++- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/apps/web/src/domains/chat/chat-layout.tsx b/apps/web/src/domains/chat/chat-layout.tsx index af1fe5e27c1..62c2cd1848f 100644 --- a/apps/web/src/domains/chat/chat-layout.tsx +++ b/apps/web/src/domains/chat/chat-layout.tsx @@ -19,7 +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 } from "@/domains/chat/components/update-available-sidebar-entry.js"; +import { + UpdateAvailableSidebarEntry, + useIsUpdateBannerVisible, +} from "@/domains/chat/components/update-available-sidebar-entry.js"; import { ChatLayoutHeader } from "./chat-layout-header.js"; /** @@ -320,13 +323,28 @@ export function ChatLayout() { const isLibraryActive = location.pathname.startsWith("/assistant/library"); - const updateBanner = useMemo( - () => - lifecycle.assistantId ? ( - - ) : null, - [lifecycle.assistantId], + 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 ? ( + + ) : undefined; const renderSideMenu = useCallback( (args: SideMenuRenderArgs): ReactNode => ( diff --git a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx index 58406427fb6..d70a8acebb4 100644 --- a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx +++ b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx @@ -10,7 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, X } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@vellum/design-library"; import { toast } from "@vellum/design-library/components/toast"; @@ -26,6 +26,7 @@ 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; @@ -53,15 +54,45 @@ function writeDismissRecord(version: string): void { 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); const [isPollingUpgrade, setIsPollingUpgrade] = useState(false); const targetVersionRef = useRef(null); + const pollStartedAtRef = useRef(0); const { data: assistant } = useQuery( assistantsRetrieveOptions({ path: { id: assistantId } }), @@ -76,10 +107,20 @@ export function UpdateAvailableSidebarEntry({ 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; }; @@ -115,6 +156,10 @@ export function UpdateAvailableSidebarEntry({ return record?.version === latestVersion; }, [latestVersion]); + useEffect(() => { + setDismissed(false); + }, [latestVersion]); + const avatar = useAssistantAvatar(assistantId); const upgradeMutation = useMutation({ @@ -142,6 +187,7 @@ export function UpdateAvailableSidebarEntry({ result.detail ?? `Update to ${result.version ?? latestVersion ?? "latest"} initiated.`, ); + pollStartedAtRef.current = Date.now(); setIsPollingUpgrade(true); queryClient.invalidateQueries({ queryKey: assistantsRetrieveQueryKey({ @@ -158,9 +204,16 @@ export function UpdateAvailableSidebarEntry({ writeDismissRecord(latestVersion); } setDismissed(true); - }, [latestVersion]); + onVisibilityChange?.(false); + }, [latestVersion, onVisibilityChange]); + + const isVisible = upgradeAvailable && !dismissed && !isDismissedForVersion; + + useEffect(() => { + onVisibilityChange?.(isVisible); + }, [isVisible, onVisibilityChange]); - if (!upgradeAvailable || dismissed || isDismissedForVersion) { + if (!isVisible) { return null; } From 32a4dbcff4f3810215b4305b2fbab92f4e3571c6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 19:26:24 +0000 Subject: [PATCH 3/5] fix: truncate long version strings in update banner Adds `truncate` class to the version text so long pre-release version strings (e.g. 0.8.3-local.20260520144955.4...) are clipped with an ellipsis instead of overflowing the sidebar. A `title` attribute preserves the full version on hover. Co-Authored-By: Jason Zhou --- .../domains/chat/components/update-available-sidebar-entry.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx index d70a8acebb4..bd45f94cfcd 100644 --- a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx +++ b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx @@ -264,8 +264,9 @@ export function UpdateAvailableSidebarEntry({

New version — {latestVersion}

From 4dd5d46dd72145c23b00db2c883e82b62311678e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 19:30:32 +0000 Subject: [PATCH 4/5] refactor(web): restructure banner layout to flex-col with separate row divs Top row: avatar + title + X dismiss button Bottom row: Upgrade now + Upgrade later buttons Parent container uses flex-direction column Co-Authored-By: Jason Zhou --- .../update-available-sidebar-entry.tsx | 88 +++++++++---------- 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx index bd45f94cfcd..4b2a92cb19c 100644 --- a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx +++ b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx @@ -222,24 +222,14 @@ export function UpdateAvailableSidebarEntry({ return (
- - -
+
{avatar.components ? ( )} -
-

- New version — {latestVersion} -

- -
- - -
-
+

+ New version — {latestVersion} +

+ + +
+ +
+ +
); From 559011f7f6ed52136f4d3f3941183d8d2414820c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 19:50:59 +0000 Subject: [PATCH 5/5] fix(web): prevent button overflow in update banner with min-w-0 and flex-1 Co-Authored-By: Jason Zhou --- .../chat/components/update-available-sidebar-entry.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx index 4b2a92cb19c..e56d20a15ba 100644 --- a/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx +++ b/apps/web/src/domains/chat/components/update-available-sidebar-entry.tsx @@ -271,10 +271,11 @@ export function UpdateAvailableSidebarEntry({
-
+