From 8912be8e2d3226ec9eb795221285a723680bd785 Mon Sep 17 00:00:00 2001 From: Kirill Serditov Date: Wed, 25 Mar 2026 19:21:56 +0300 Subject: [PATCH 1/5] Improve desktop update button with status-based visuals and rocket icon Extract visual state logic into a lookup table in desktopUpdate.logic.ts, add RocketUpdateIcon with download progress fill to Icons.tsx, and consolidate formatRelativeTime into shared utils. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/Icons.tsx | 61 ++++++++++++ apps/web/src/components/Sidebar.tsx | 93 ++++++++++--------- .../components/desktopUpdate.logic.test.ts | 54 +++++++++++ .../web/src/components/desktopUpdate.logic.ts | 60 +++++++++--- apps/web/src/index.css | 10 ++ apps/web/src/lib/utils.ts | 17 ++++ 6 files changed, 238 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7d210fa173..35748af4b9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -309,3 +309,64 @@ export const OpenCodeIcon: Icon = (props) => ( ); + +const ROCKET_BODY = + "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"; +const ROCKET_FINS = "M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"; + +/** Rocket icon with optional bottom-to-top fill overlay for download progress (0–100). */ +export function RocketUpdateIcon({ + fillPercent, + ...props +}: SVGProps & { fillPercent?: number | null }) { + const clipId = useId(); + const showFill = typeof fillPercent === "number"; + + return ( + + {showFill && ( + + + + + + )} + + + {showFill && ( + + + + + )} + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 923c30b2f9..20bf3a3aa9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,7 +5,6 @@ import { FolderIcon, GitPullRequestIcon, PlusIcon, - RocketIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -45,7 +44,13 @@ import { } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { + formatRelativeTime, + isLinuxPlatform, + isMacPlatform, + newCommandId, + newProjectId, +} from "../lib/utils"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -55,6 +60,7 @@ import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { RocketUpdateIcon } from "./Icons"; import { toastManager } from "./ui/toast"; import { getArm64IntelBuildWarningDescription, @@ -62,8 +68,8 @@ import { getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, + resolveDesktopUpdateButtonVisualState, shouldShowArm64IntelBuildWarning, - shouldHighlightDesktopUpdateError, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; @@ -120,16 +126,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { } as const; const loadedProjectFaviconSrcs = new Set(); -function formatRelativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -1497,7 +1493,7 @@ export default function Sidebar() { const desktopUpdateTooltip = desktopUpdateState ? getDesktopUpdateButtonTooltip(desktopUpdateState) - : "Update available"; + : ""; const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState @@ -1509,17 +1505,12 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; + const desktopUpdateVisual = desktopUpdateState + ? resolveDesktopUpdateButtonVisualState(desktopUpdateState) + : null; const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled ? "cursor-not-allowed opacity-60" - : "hover:bg-accent hover:text-foreground"; - const desktopUpdateButtonClasses = - desktopUpdateState?.status === "downloaded" - ? "text-emerald-500" - : desktopUpdateState?.status === "downloading" - ? "text-sky-400" - : shouldHighlightDesktopUpdateError(desktopUpdateState) - ? "text-rose-500 animate-pulse" - : "text-amber-500 animate-pulse"; + : ""; const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); @@ -1624,36 +1615,49 @@ export default function Sidebar() { ); + const downloadPercent = + desktopUpdateState?.status === "downloading" && + typeof desktopUpdateState.downloadPercent === "number" + ? desktopUpdateState.downloadPercent + : null; + + const updateButton = + showDesktopUpdateButton && desktopUpdateVisual ? ( + + + + + } + /> + {desktopUpdateTooltip} + + ) : null; + return ( <> {isElectron ? ( <> {wordmark} - {showDesktopUpdateButton && ( - - - - - } - /> - {desktopUpdateTooltip} - - )} +
{updateButton}
) : ( - + {wordmark} + {updateButton} )} @@ -1664,7 +1668,8 @@ export default function Sidebar() { Intel build on Apple Silicon {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( + {desktopUpdateButtonAction === "download" || + desktopUpdateButtonAction === "install" ? ( + )} + {entry.prNumber && entry.prUrl && ( + + )} + + + ); +} + +function ReleaseCard({ + tagName, + publishedAt, + body, + htmlUrl, + isCurrent, + isHighlighted, + innerRef, +}: { + tagName: string; + publishedAt: string | null; + body: string | null; + htmlUrl: string; + isCurrent: boolean; + isHighlighted: boolean; + innerRef?: ((el: HTMLDivElement | null) => void) | undefined; +}) { + const parsed = body ? parseReleaseBody(body) : null; + const hasParsedContent = parsed && parsed.sections.some((s) => s.entries.length > 0); + + return ( +
+
+ {tagName} + {isCurrent && ( + + current + + )} + + {formatRelativeTime(publishedAt)} + + +
+ + {hasParsedContent + ? parsed.sections.map((section) => ( +
+

+ {section.title} +

+
    + {section.entries.map((entry) => ( + + ))} +
+
+ )) + : body && ( +

+ {body.slice(0, 200)} + {body.length > 200 ? "…" : ""} +

+ )} + + {!body &&

No release notes.

} + + {parsed?.fullChangelogUrl && ( + + )} +
+ ); +} + +function isVersionMatch(tagName: string, target: string): boolean { + const version = tagName.replace(/^v/, ""); + return tagName === target || version === target.replace(/^v/, ""); +} + +export function ChangelogDialog({ open, onOpenChange, highlightVersion }: ChangelogDialogProps) { + const { + data: releases, + isLoading, + error, + } = useQuery({ ...changelogQueryOptions(), enabled: open }); + const [activeTag, setActiveTag] = useState(null); + const cardRefs = useRef>(new Map()); + + const scrollToVersion = useCallback((tag: string) => { + setActiveTag(tag); + const el = cardRefs.current.get(tag); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + // Scroll to highlighted version on open + useEffect(() => { + if (!open || !releases?.length || !highlightVersion) return; + const match = releases.find((r) => isVersionMatch(r.tag_name, highlightVersion)); + if (match) { + // Defer to next frame so dialog has rendered + requestAnimationFrame(() => scrollToVersion(match.tag_name)); + } + }, [open, releases, highlightVersion, scrollToVersion]); + + return ( + + + + Changelog + Release history for T3 Code + + + {isLoading && ( +
+ +
+ )} + + {error && ( +
+ Failed to load releases. Check your internet connection. +
+ )} + + {releases && releases.length > 0 && ( +
+ {/* Version sidebar */} + + + {/* Release cards */} + + {releases.map((release) => { + const version = release.tag_name.replace(/^v/, ""); + const isCurrent = version === APP_VERSION || release.tag_name === APP_VERSION; + const isHighlighted = + !!highlightVersion && isVersionMatch(release.tag_name, highlightVersion); + + return ( + { + if (el) cardRefs.current.set(release.tag_name, el); + else cardRefs.current.delete(release.tag_name); + }} + /> + ); + })} + +
+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/lib/changelogReactQuery.ts b/apps/web/src/lib/changelogReactQuery.ts new file mode 100644 index 0000000000..50185a7894 --- /dev/null +++ b/apps/web/src/lib/changelogReactQuery.ts @@ -0,0 +1,37 @@ +import { queryOptions } from "@tanstack/react-query"; +import { GITHUB_REPO_SLUG } from "~/branding"; + +export interface GitHubRelease { + tag_name: string; + name: string | null; + body: string | null; + /** `null` for draft releases (not returned by the public endpoint, but defensive). */ + published_at: string | null; + html_url: string; +} + +export const changelogQueryKeys = { + releases: () => ["changelog", "releases"] as const, +}; + +export function changelogQueryOptions() { + return queryOptions({ + queryKey: changelogQueryKeys.releases(), + queryFn: async (): Promise => { + const response = await fetch( + `https://api.github.com/repos/${GITHUB_REPO_SLUG}/releases?per_page=20`, + { headers: { Accept: "application/vnd.github+json" } }, + ); + if (!response.ok) { + const message = + response.status === 403 + ? "GitHub API rate limit exceeded. Try again later." + : `Failed to fetch releases (${response.status})`; + throw new Error(message); + } + return response.json() as Promise; + }, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + }); +} diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts index 40443f67e0..6b74e726b2 100644 --- a/apps/web/src/nativeApi.ts +++ b/apps/web/src/nativeApi.ts @@ -24,3 +24,13 @@ export function ensureNativeApi(): NativeApi { } return api; } + +/** Open a URL in the system browser, using the native API when available. */ +export function openExternalUrl(url: string): void { + const api = readNativeApi(); + if (api) { + void api.shell.openExternal(url); + } else { + window.open(url, "_blank", "noopener,noreferrer"); + } +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 62a27edba9..fb3976d5aa 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -13,7 +13,9 @@ import { patchCustomModels, resolveAppModelSelectionState, } from "../modelSelection"; -import { APP_VERSION } from "../branding"; +import { APP_VERSION, GITHUB_REPO_URL } from "../branding"; +import { ChangelogDialog } from "../components/ChangelogDialog"; +import { GitHubIcon } from "../components/Icons"; import { Button } from "../components/ui/button"; import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { Input } from "../components/ui/input"; @@ -35,7 +37,7 @@ import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; -import { ensureNativeApi, readNativeApi } from "../nativeApi"; +import { ensureNativeApi, openExternalUrl, readNativeApi } from "../nativeApi"; const THEME_OPTIONS = [ { @@ -192,6 +194,7 @@ function SettingsRouteView() { const { theme, setTheme } = useTheme(); const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const [settingsChangelogOpen, setSettingsChangelogOpen] = useState(false); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); const [openInstallProviders, setOpenInstallProviders] = useState>({ @@ -992,10 +995,38 @@ function SettingsRouteView() { title="Version" description="Current application version." control={ - {APP_VERSION} +
+ + + openExternalUrl(GITHUB_REPO_URL)} + > + + + } + /> + Open GitHub repository + +
} /> + + From 683e5233e9c580220e756349ecbd7ac1b010660f Mon Sep 17 00:00:00 2001 From: Kirill Serditov Date: Thu, 26 Mar 2026 04:09:02 +0300 Subject: [PATCH 5/5] Show rate-limit error message from GitHub API in changelog dialog Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/ChangelogDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChangelogDialog.tsx b/apps/web/src/components/ChangelogDialog.tsx index 3c879de86f..1dbcd81e2b 100644 --- a/apps/web/src/components/ChangelogDialog.tsx +++ b/apps/web/src/components/ChangelogDialog.tsx @@ -254,7 +254,7 @@ export function ChangelogDialog({ open, onOpenChange, highlightVersion }: Change {error && (
- Failed to load releases. Check your internet connection. + {error.message || "Failed to load releases. Check your internet connection."}
)}