Skip to content

Commit 5e6bb75

Browse files
authored
new version update widget (#581)
## Add version update notification system This PR adds a new feature that notifies users when a newer version of Bifrost is available. It also improves the promotional card stack component with better animations and dismissible cards. ## Changes - Added DNS prefetch and preconnect hints for getbifrost.ai in the layout - Created a new API endpoint to fetch the latest release information from getbifrost.ai - Implemented semantic version comparison logic to detect when a newer version is available - Enhanced the PromoCardStack component with: - Card dismissal animations - Stacked card visual effect - Improved styling and interaction - Added a new release notification card with link to release notes - Improved styling of promotional cards with better typography and layout ## Type of change - [x] Feature - [x] UI (Next.js) ## Affected areas - [x] Transports (HTTP) - [x] UI (Next.js) ## How to test 1. Run the application with an older version than what's available on getbifrost.ai 2. The sidebar should display a new release notification card 3. Test dismissing the card by clicking the X button 4. Verify the animation works correctly ```sh # Core/Transports go version go test ./... # UI cd ui pnpm i pnpm build ``` ## Screenshots/Recordings The new release notification card shows in the sidebar with the ability to dismiss it. The card stack now has a more polished appearance with cards that stack visually. ## Breaking changes - [x] No ## Security considerations The application now makes external requests to getbifrost.ai to check for version updates. This is done with appropriate timeouts and error handling to prevent issues if the external service is unavailable.
2 parents 6413388 + f55d1b4 commit 5e6bb75

File tree

7 files changed

+259
-41
lines changed

7 files changed

+259
-41
lines changed

transports/bifrost-http/handlers/init.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ func SetLogger(l schemas.Logger) {
1313
// SetVersion sets the version for the application.
1414
func SetVersion(v string) {
1515
version = v
16-
}
16+
}

ui/app/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { SidebarProvider } from "@/components/ui/sidebar";
99
import { WebSocketProvider } from "@/hooks/useWebSocket";
1010
import { getErrorMessage, ReduxProvider, useGetCoreConfigQuery } from "@/lib/store";
1111
import { Geist, Geist_Mono } from "next/font/google";
12+
import { NuqsAdapter } from "nuqs/adapters/next/app";
1213
import { useEffect } from "react";
1314
import { toast, Toaster } from "sonner";
1415
import "./globals.css";
15-
import { NuqsAdapter } from "nuqs/adapters/next/app";
1616

1717
const geistSans = Geist({
1818
variable: "--font-geist-sans",
@@ -50,6 +50,10 @@ function AppContent({ children }: { children: React.ReactNode }) {
5050
export default function RootLayout({ children }: { children: React.ReactNode }) {
5151
return (
5252
<html lang="en" suppressHydrationWarning>
53+
<head>
54+
<link rel="dns-prefetch" href="https://getbifrost.ai" />
55+
<link rel="preconnect" href="https://getbifrost.ai" />
56+
</head>
5357
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
5458
<ProgressProvider>
5559
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>

ui/components/sidebar.tsx

Lines changed: 99 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ import {
1414
} from "@/components/ui/sidebar";
1515
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
1616
import { useWebSocket } from "@/hooks/useWebSocket";
17-
import { useGetCoreConfigQuery, useGetVersionQuery } from "@/lib/store";
17+
import { useGetCoreConfigQuery, useGetLatestReleaseQuery, useGetVersionQuery } from "@/lib/store";
1818
import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react";
1919
import { useTheme } from "next-themes";
2020
import Image from "next/image";
2121
import Link from "next/link";
2222
import { usePathname } from "next/navigation";
23-
import { useEffect, useState } from "react";
23+
import { useEffect, useMemo, useState } from "react";
2424
import { ThemeToggle } from "./themeToggle";
2525
import { PromoCardStack } from "./ui/promoCardStack";
2626

@@ -137,29 +137,29 @@ const externalLinks = [
137137
},
138138
];
139139

140-
// Promotional cards configuration
141-
const promoCards = [
142-
{
143-
title: "Need help with production setup?",
144-
description: (
145-
<>
146-
We offer help with production setup including custom integrations and dedicated support.
147-
<br />
148-
<br />
149-
Book a demo with our team{" "}
150-
<Link
151-
href="https://calendly.com/maximai/bifrost-demo?utm_source=bfd_sdbr"
152-
target="_blank"
153-
className="text-primary"
154-
rel="noopener noreferrer"
155-
>
156-
here
157-
</Link>
158-
.
159-
</>
160-
),
161-
},
162-
];
140+
// Base promotional card (memoized outside component to prevent recreation)
141+
const productionSetupHelpCard = {
142+
id: "production-setup",
143+
title: "Need help with production setup?",
144+
description: (
145+
<>
146+
We offer help with production setup including custom integrations and dedicated support.
147+
<br />
148+
<br />
149+
Book a demo with our team{" "}
150+
<Link
151+
href="https://calendly.com/maximai/bifrost-demo?utm_source=bfd_sdbr"
152+
target="_blank"
153+
className="text-primary font-medium underline"
154+
rel="noopener noreferrer"
155+
>
156+
here
157+
</Link>
158+
.
159+
</>
160+
),
161+
dismissible: false,
162+
};
163163

164164
const SidebarItem = ({
165165
item,
@@ -207,11 +207,60 @@ const SidebarItem = ({
207207
);
208208
};
209209

210+
// Helper function to compare semantic versions
211+
const compareVersions = (v1: string, v2: string): number => {
212+
// Remove 'v' prefix if present
213+
const cleanV1 = v1.startsWith("v") ? v1.slice(1) : v1;
214+
const cleanV2 = v2.startsWith("v") ? v2.slice(1) : v2;
215+
216+
// Split into main version and prerelease
217+
const [mainV1, prereleaseV1] = cleanV1.split("-");
218+
const [mainV2, prereleaseV2] = cleanV2.split("-");
219+
220+
// Compare main version numbers (major.minor.patch)
221+
const partsV1 = mainV1.split(".").map(Number);
222+
const partsV2 = mainV2.split(".").map(Number);
223+
224+
for (let i = 0; i < Math.max(partsV1.length, partsV2.length); i++) {
225+
const num1 = partsV1[i] || 0;
226+
const num2 = partsV2[i] || 0;
227+
228+
if (num1 > num2) return 1;
229+
if (num1 < num2) return -1;
230+
}
231+
232+
// If main versions are equal, check prerelease
233+
// Version without prerelease is higher than version with prerelease
234+
if (!prereleaseV1 && prereleaseV2) return 1;
235+
if (prereleaseV1 && !prereleaseV2) return -1;
236+
237+
// Both have prereleases, compare them
238+
if (prereleaseV1 && prereleaseV2) {
239+
// Extract prerelease number (e.g., "prerelease1" -> 1)
240+
const prereleaseNum1 = parseInt(prereleaseV1.replace(/\D/g, "")) || 0;
241+
const prereleaseNum2 = parseInt(prereleaseV2.replace(/\D/g, "")) || 0;
242+
243+
if (prereleaseNum1 > prereleaseNum2) return 1;
244+
if (prereleaseNum1 < prereleaseNum2) return -1;
245+
}
246+
247+
return 0;
248+
};
249+
210250
export default function AppSidebar() {
211251
const pathname = usePathname();
212252
const [mounted, setMounted] = useState(false);
253+
const { data: latestRelease } = useGetLatestReleaseQuery(undefined, {
254+
skip: !mounted, // Only fetch after component is mounted
255+
});
213256
const { data: version } = useGetVersionQuery();
214257
const { resolvedTheme } = useTheme();
258+
const showNewReleaseBanner = useMemo(() => {
259+
if (latestRelease && version) {
260+
return compareVersions(latestRelease.name, version) > 0;
261+
}
262+
return false;
263+
}, [latestRelease, version]);
215264

216265
// Get governance config from RTK Query
217266
const { data: coreConfig } = useGetCoreConfigQuery({});
@@ -232,6 +281,31 @@ export default function AppSidebar() {
232281

233282
const { isConnected: isWebSocketConnected } = useWebSocket();
234283

284+
// Memoize promo cards array to prevent duplicates and unnecessary re-renders
285+
const promoCards = useMemo(() => {
286+
const cards = [productionSetupHelpCard];
287+
if (showNewReleaseBanner && latestRelease) {
288+
cards.push({
289+
id: "new-release",
290+
title: `${latestRelease.name} is now available.`,
291+
description: (
292+
<div className="flex h-full flex-col gap-2">
293+
<img src={`/images/new-release-image.png`} alt="Bifrost" className="h-[95px] object-cover" />
294+
<Link
295+
href={`https://docs.getbifrost.ai/changelogs/${latestRelease.name}`}
296+
target="_blank"
297+
className="text-primary mt-auto pb-1 font-medium underline"
298+
>
299+
View release notes
300+
</Link>
301+
</div>
302+
),
303+
dismissible: true,
304+
});
305+
}
306+
return cards;
307+
}, [showNewReleaseBanner, latestRelease]);
308+
235309
return (
236310
<Sidebar className="overflow-y-clip border-none bg-transparent">
237311
<SidebarHeader className="mt-1 ml-2 flex h-12 justify-between px-0">
Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import React from "react";
1+
"use client";
2+
3+
import { cn } from "@/lib/utils";
4+
import { X } from "lucide-react";
5+
import React, { useEffect, useState } from "react";
26
import { Card, CardContent, CardHeader } from "./card";
37

48
interface PromoCardItem {
9+
id: string;
510
title: string | React.ReactElement;
611
description: string | React.ReactElement;
12+
dismissible?: boolean;
713
}
814

915
interface PromoCardStackProps {
@@ -12,22 +18,96 @@ interface PromoCardStackProps {
1218
}
1319

1420
export function PromoCardStack({ cards, className = "" }: PromoCardStackProps) {
21+
const [items, setItems] = useState(() => {
22+
return [...cards].sort((a, b) => {
23+
const aDismissible = a.dismissible !== false;
24+
const bDismissible = b.dismissible !== false;
25+
return bDismissible === aDismissible ? 0 : aDismissible ? -1 : 1;
26+
});
27+
});
28+
const [removingId, setRemovingId] = useState<string | null>(null);
29+
const [isAnimating, setIsAnimating] = useState(false);
30+
31+
useEffect(() => {
32+
const sortedCards = [...cards].sort((a, b) => {
33+
const aDismissible = a.dismissible !== false;
34+
const bDismissible = b.dismissible !== false;
35+
return bDismissible === aDismissible ? 0 : aDismissible ? -1 : 1;
36+
});
37+
setItems(sortedCards);
38+
}, [cards]);
39+
40+
const handleDismiss = (cardId: string) => {
41+
if (isAnimating) return;
42+
setIsAnimating(true);
43+
setRemovingId(cardId);
44+
45+
setTimeout(() => {
46+
setItems((prev) => prev.filter((it) => it.id !== cardId));
47+
setRemovingId(null);
48+
setIsAnimating(false);
49+
}, 400);
50+
};
51+
1552
if (!cards || cards.length === 0) {
1653
return null;
1754
}
1855

56+
const MAX_VISIBLE_CARDS = 10;
57+
const visibleCards = items.slice(0, MAX_VISIBLE_CARDS);
58+
1959
return (
20-
<div className={`flex flex-col gap-2 ${className}`}>
21-
{cards.map((card, index) => (
22-
<Card key={index} className="w-full gap-2 rounded-lg px-2.5 py-2 shadow-none">
23-
<CardHeader className="text-muted-foreground p-1 text-xs font-medium">
24-
{typeof card.title === "string" ? card.title : card.title}
25-
</CardHeader>
26-
<CardContent className="text-muted-foreground mt-0 px-1 pt-0 pb-1 text-xs">
27-
{typeof card.description === "string" ? card.description : card.description}
28-
</CardContent>
29-
</Card>
30-
))}
60+
<div className={`relative ${className}`} style={{ marginBottom: "60px", height: "130px" }}>
61+
{visibleCards.map((card, index) => {
62+
const isTopCard = index === 0;
63+
const isRemoving = removingId === card.id;
64+
const scale = 1 - index * 0.05;
65+
const yOffset = index * 10;
66+
const opacity = 1 - index * 0.2;
67+
68+
return (
69+
<div
70+
key={card.id}
71+
className="absolute right-0 left-0 transition-all duration-400 ease-out"
72+
style={{
73+
top: isRemoving ? 0 : `${yOffset}px`,
74+
transform: isRemoving ? "translateX(-120%) rotate(-8deg)" : `scale(${scale})`,
75+
opacity: isRemoving ? 0 : opacity,
76+
zIndex: visibleCards.length - index,
77+
transformOrigin: "center center",
78+
pointerEvents: isTopCard && !isAnimating ? "auto" : "none",
79+
height: "180px",
80+
}}
81+
>
82+
<Card
83+
className={cn(
84+
"flex h-full w-full flex-col gap-0 rounded-lg px-2.5 py-2",
85+
visibleCards.length < 2 ? "shadow-none" : "shadow-md",
86+
)}
87+
>
88+
<CardHeader className="text-muted-foreground flex-shrink-0 p-1 text-sm font-medium">
89+
<div className="flex items-start justify-between">
90+
<div className="min-w-0 flex-1">{typeof card.title === "string" ? card.title : card.title}</div>
91+
{card.dismissible !== false && isTopCard && (
92+
<button
93+
aria-label="Dismiss"
94+
type="button"
95+
onClick={() => handleDismiss(card.id)}
96+
disabled={isAnimating}
97+
className="hover:text-foreground text-muted-foreground -m-1 flex-shrink-0 rounded p-1 disabled:opacity-50"
98+
>
99+
<X className="h-3.5 w-3.5" />
100+
</button>
101+
)}
102+
</div>
103+
</CardHeader>
104+
<CardContent className="text-muted-foreground mt-0 flex-1 overflow-y-auto px-1 pt-0 pb-1 text-xs">
105+
{typeof card.description === "string" ? card.description : card.description}
106+
</CardContent>
107+
</Card>
108+
</div>
109+
);
110+
})}
31111
</div>
32112
);
33113
}

ui/lib/store/apis/configApi.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { BifrostConfig, CoreConfig } from "@/lib/types/config";
1+
import { BifrostConfig, CoreConfig, LatestReleaseResponse } from "@/lib/types/config";
2+
import axios from "axios";
23
import { baseApi } from "./baseApi";
34

45
export const configApi = baseApi.injectEndpoints({
@@ -19,6 +20,52 @@ export const configApi = baseApi.injectEndpoints({
1920
}),
2021
}),
2122

23+
// Get latest release from public site
24+
getLatestRelease: builder.query<LatestReleaseResponse, void>({
25+
queryFn: async (_arg, { signal }) => {
26+
try {
27+
const response = await axios.get('https://getbifrost.ai/latest-release', {
28+
timeout: 3000, // 3 second timeout
29+
signal,
30+
headers: {
31+
Accept: 'application/json',
32+
},
33+
maxRedirects: 5,
34+
validateStatus: (status) => status >= 200 && status < 300,
35+
})
36+
const data = response.data as any
37+
const normalized: LatestReleaseResponse = {
38+
name: data.name ?? data.tag ?? data.version ?? '',
39+
changelogUrl: data.changelogUrl ?? data.changelog_url ?? '',
40+
}
41+
return { data: normalized }
42+
} catch (error) {
43+
if (axios.isAxiosError(error)) {
44+
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
45+
console.warn('Latest release fetch timed out after 3s')
46+
return {
47+
error: {
48+
status: 'TIMEOUT_ERROR',
49+
error: 'Request timeout',
50+
data: { error: { message: 'Request timeout' } },
51+
},
52+
}
53+
}
54+
console.error('Latest release fetch error:', error.message)
55+
} else {
56+
console.error('Latest release fetch error:', error)
57+
}
58+
return {
59+
error: {
60+
status: 'FETCH_ERROR',
61+
error: String(error),
62+
data: { error: { message: 'Network error' } },
63+
},
64+
}
65+
}
66+
},
67+
keepUnusedDataFor: 300, // Cache for 5 minutes (seconds)
68+
}),
2269
// Update core configuration
2370
updateCoreConfig: builder.mutation<null, CoreConfig>({
2471
query: (data) => ({
@@ -31,4 +78,11 @@ export const configApi = baseApi.injectEndpoints({
3178
}),
3279
});
3380

34-
export const { useGetVersionQuery, useGetCoreConfigQuery, useUpdateCoreConfigMutation, useLazyGetCoreConfigQuery } = configApi;
81+
export const {
82+
useGetVersionQuery,
83+
useGetCoreConfigQuery,
84+
useUpdateCoreConfigMutation,
85+
useLazyGetCoreConfigQuery,
86+
useGetLatestReleaseQuery,
87+
useLazyGetLatestReleaseQuery,
88+
} = configApi;

0 commit comments

Comments
 (0)