authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-11 18:53:40-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 18:53:40-04:00
log52e5a75a872289040df85621d7a82ea9cba05182
tree49387879875a34cf9e3ede403315c117b61ffa34
parentc196f422ed387a1cc2cdb671b0472782e5610339
signature Signed by PGP key B5690EEEBB952194

feat(web): compact sidebar footer actions (#6210)


2 files changed, 190 insertions(+), 138 deletions(-)

apps/web/src/components/sidebar/SidebarChrome.tsx+57-39
...@@ -5,7 +5,7 @@ import {...@@ -5,7 +5,7 @@ import {
5 SettingsIcon,5 SettingsIcon,
6} from "lucide-react";6} from "lucide-react";
7import { memo, useCallback } from "react";7import { memo, useCallback } from "react";
8import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";8import { Link, useLocation, useNavigate } from "@tanstack/react-router";
99
10import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";10import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
11import { cn } from "../../lib/utils";11import { cn } from "../../lib/utils";
...@@ -27,8 +27,9 @@ import {...@@ -27,8 +27,9 @@ import {
27 SidebarTrigger,27 SidebarTrigger,
28 useSidebar,28 useSidebar,
29} from "../ui/sidebar";29} from "../ui/sidebar";
30import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
30import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";31import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
31import { SidebarUpdatePill } from "./SidebarUpdatePill";32import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
3233
33export const SidebarChromeHeader = memo(function SidebarChromeHeader({34export const SidebarChromeHeader = memo(function SidebarChromeHeader({
34 isElectron,35 isElectron,
...@@ -119,7 +120,6 @@ function T3Wordmark() {...@@ -119,7 +120,6 @@ function T3Wordmark() {
119export const SidebarChromeFooter = memo(function SidebarChromeFooter() {120export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
120 const navigate = useNavigate();121 const navigate = useNavigate();
121 const { isMobile, setOpenMobile } = useSidebar();122 const { isMobile, setOpenMobile } = useSidebar();
122 const canGoBack = useCanGoBack();
123 const currentFooterPage = useLocation({123 const currentFooterPage = useLocation({
124 select: (location) =>124 select: (location) =>
125 location.pathname === "/usage"125 location.pathname === "/usage"
...@@ -154,54 +154,72 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -154,54 +154,72 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
154154
155 const handleBackClick = useCallback(() => {155 const handleBackClick = useCallback(() => {
156 closeMobileSidebar();156 closeMobileSidebar();
157 if (canGoBack) {
158 window.history.back();
159 return;
160 }
161 void navigate({ to: "/" });157 void navigate({ to: "/" });
162 }, [canGoBack, closeMobileSidebar, navigate]);158 }, [closeMobileSidebar, navigate]);
163159
164 return (160 return (
165 <SidebarFooter className="p-[var(--sidebar-content-inset)]">161 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
166 <SidebarProviderUpdatePill />162 <SidebarProviderUpdatePill />
167 <SidebarUpdatePill />163 <SidebarUpdateArchitectureWarning />
168 <SidebarMenu>164 <SidebarMenu className="flex-row items-center">
169 {currentFooterPage === "pull-requests" ? (165 {currentFooterPage ? (
170 <SidebarMenuItem>166 <SidebarMenuItem className="min-w-0 flex-1">
171 <SidebarMenuButton onClick={handleBackClick}>
172 <ArrowLeftIcon />
173 <span>Back</span>
174 </SidebarMenuButton>
175 </SidebarMenuItem>
176 ) : pullRequestsSupported ? (
177 <SidebarMenuItem>
178 <SidebarMenuButton onClick={handlePullRequestsClick}>
179 <GitPullRequestIcon />
180 <span>Pull Requests</span>
181 </SidebarMenuButton>
182 </SidebarMenuItem>
183 ) : null}
184 {currentFooterPage === "usage" ? (
185 <SidebarMenuItem>
186 <SidebarMenuButton onClick={handleBackClick}>167 <SidebarMenuButton onClick={handleBackClick}>
187 <ArrowLeftIcon />168 <ArrowLeftIcon />
188 <span>Back</span>169 <span>Back</span>
189 </SidebarMenuButton>170 </SidebarMenuButton>
190 </SidebarMenuItem>171 </SidebarMenuItem>
191 ) : (172 ) : (
192 <SidebarMenuItem>173 <>
193 <SidebarMenuButton onClick={handleUsageClick}>174 <SidebarMenuItem className="shrink-0">
194 <ChartNoAxesColumnIcon />175 <Tooltip>
195 <span>Usage</span>176 <TooltipTrigger
196 </SidebarMenuButton>177 render={
197 </SidebarMenuItem>178 <SidebarMenuButton
179 aria-label="Settings"
180 onClick={handleSettingsClick}
181 size="icon"
182 >
183 <SettingsIcon />
184 </SidebarMenuButton>
185 }
186 />
187 <TooltipPopup side="top">Settings</TooltipPopup>
188 </Tooltip>
189 </SidebarMenuItem>
190 {pullRequestsSupported ? (
191 <SidebarMenuItem className="shrink-0">
192 <Tooltip>
193 <TooltipTrigger
194 render={
195 <SidebarMenuButton
196 aria-label="Pull Requests"
197 onClick={handlePullRequestsClick}
198 size="icon"
199 >
200 <GitPullRequestIcon />
201 </SidebarMenuButton>
202 }
203 />
204 <TooltipPopup side="top">Pull Requests</TooltipPopup>
205 </Tooltip>
206 </SidebarMenuItem>
207 ) : null}
208 <SidebarMenuItem className="shrink-0">
209 <Tooltip>
210 <TooltipTrigger
211 render={
212 <SidebarMenuButton aria-label="Usage" onClick={handleUsageClick} size="icon">
213 <ChartNoAxesColumnIcon />
214 </SidebarMenuButton>
215 }
216 />
217 <TooltipPopup side="top">Usage</TooltipPopup>
218 </Tooltip>
219 </SidebarMenuItem>
220 </>
198 )}221 )}
199 <SidebarMenuItem>222 <SidebarUpdatePill />
200 <SidebarMenuButton onClick={handleSettingsClick}>
201 <SettingsIcon />
202 <span>Settings</span>
203 </SidebarMenuButton>
204 </SidebarMenuItem>
205 </SidebarMenu>223 </SidebarMenu>
206 </SidebarFooter>224 </SidebarFooter>
207 );225 );
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+133-99
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";1import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
2import { useCallback, useState } from "react";2import { useCallback, useState } from "react";
3import { isElectron } from "../../env";3import { isElectron } from "../../env";
4import { cn } from "../../lib/utils";
4import { ensureLocalApi } from "../../localApi";5import { ensureLocalApi } from "../../localApi";
5import { useDesktopUpdateState } from "../../state/desktopUpdate";6import { useDesktopUpdateState } from "../../state/desktopUpdate";
6import { stackedThreadToast, toastManager } from "../ui/toast";7import { stackedThreadToast, toastManager } from "../ui/toast";
7import {8import {
9 canCheckForUpdate,
8 getArm64IntelBuildWarningDescription,10 getArm64IntelBuildWarningDescription,
9 getDesktopUpdateActionError,11 getDesktopUpdateActionError,
10 getDesktopUpdateButtonTooltip,12 getDesktopUpdateButtonTooltip,
...@@ -12,12 +14,12 @@ import {...@@ -12,12 +14,12 @@ import {
12 isDesktopUpdateButtonDisabled,14 isDesktopUpdateButtonDisabled,
13 resolveDesktopUpdateButtonAction,15 resolveDesktopUpdateButtonAction,
14 shouldShowArm64IntelBuildWarning,16 shouldShowArm64IntelBuildWarning,
15 shouldShowDesktopUpdateButton,
16 shouldToastDesktopUpdateActionResult,17 shouldToastDesktopUpdateActionResult,
17} from "../desktopUpdate.logic";18} from "../desktopUpdate.logic";
18import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";19import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
19import { Alert, AlertDescription, AlertTitle } from "../ui/alert";20import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
20import { Separator } from "../ui/separator";21import { Separator } from "../ui/separator";
22import { SidebarMenuItem } from "../ui/sidebar";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";23import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2224
23function keyReleaseNoteItems(items: ReadonlyArray<string>) {25function keyReleaseNoteItems(items: ReadonlyArray<string>) {
...@@ -41,16 +43,29 @@ function SidebarUpdateReleaseNotesTooltip({...@@ -41,16 +43,29 @@ function SidebarUpdateReleaseNotesTooltip({
41 }43 }
4244
43 return (45 return (
44 <div className="w-120 max-w-[calc(100vw-2rem)] text-left">46 <div className="w-fit max-w-[min(24rem,calc(100vw-2rem))] text-left">
45 <div className="px-1">47 <div className="px-1">
46 <div className="text-sm leading-5 font-medium">{tooltip}</div>48 {state.status === "available" ? (
49 <div>
50 <div className="whitespace-nowrap text-sm leading-5 font-medium">
51 Update ready to download
52 </div>
53 {state.availableVersion ? (
54 <div className="mt-0.5 text-xs leading-4 text-update-foreground">
55 {state.availableVersion}
56 </div>
57 ) : null}
58 </div>
59 ) : (
60 <div className="text-sm leading-5 font-medium">{tooltip}</div>
61 )}
47 </div>62 </div>
48 <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">63 <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">
49 {state.releaseNotes.map((releaseNote, index) => (64 {state.releaseNotes.map((releaseNote, index) => (
50 <div key={releaseNote.version}>65 <div key={releaseNote.version}>
51 {index > 0 && <Separator className="my-3 bg-border/60" />}66 {index > 0 && <Separator className="my-3 bg-border/60" />}
52 <section>67 <section>
53 <h3 className="text-muted-foreground text-xs leading-4 font-semibold">68 <h3 className="text-foreground text-xs leading-4 font-semibold">
54 {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}69 {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
55 </h3>70 </h3>
56 <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">71 <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">
...@@ -68,24 +83,50 @@ function SidebarUpdateReleaseNotesTooltip({...@@ -68,24 +83,50 @@ function SidebarUpdateReleaseNotesTooltip({
68 );83 );
69}84}
7085
86export function SidebarUpdateArchitectureWarning() {
87 return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null;
88}
89
90function SidebarUpdateArchitectureWarningContent() {
91 const state = useDesktopUpdateState();
92 const visible = shouldShowArm64IntelBuildWarning(state);
93 const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null;
94
95 if (!visible || !description) return null;
96
97 return (
98 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs">
99 <TriangleAlertIcon />
100 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
101 <AlertDescription>{description}</AlertDescription>
102 </Alert>
103 );
104}
105
71export function SidebarUpdatePill() {106export function SidebarUpdatePill() {
107 return isElectron ? <SidebarUpdateControl /> : null;
108}
109
110function SidebarUpdateControl() {
72 const state = useDesktopUpdateState();111 const state = useDesktopUpdateState();
73 const [dismissed, setDismissed] = useState(false);
74 const [isActionPending, setIsActionPending] = useState(false);112 const [isActionPending, setIsActionPending] = useState(false);
75113
76 const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed;
77 const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available";
78 const disabled = isDesktopUpdateButtonDisabled(state);
79 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";114 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
80115 const isDownloading = state?.status === "downloading";
81 const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state);116 const isUpdateState = action !== "none" || isDownloading;
82 const arm64Description =117 const tooltip = isUpdateState
83 state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null;118 ? state
119 ? getDesktopUpdateButtonTooltip(state)
120 : "Update available"
121 : state?.status === "checking"
122 ? "Checking for updates…"
123 : "Check for updates";
124 const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state);
84125
85 const handleAction = useCallback(async () => {126 const handleAction = useCallback(async () => {
86 const bridge = window.desktopBridge;127 const bridge = window.desktopBridge;
87 if (!bridge || !state) return;128 if (!bridge || !state) return;
88 if (disabled || action === "none" || isActionPending) return;129 if (disabled || isActionPending) return;
89130
90 setIsActionPending(true);131 setIsActionPending(true);
91132
...@@ -165,99 +206,92 @@ export function SidebarUpdatePill() {...@@ -165,99 +206,92 @@ export function SidebarUpdatePill() {
165 );206 );
166 })207 })
167 .finally(() => setIsActionPending(false));208 .finally(() => setIsActionPending(false));
209 return;
168 }210 }
169 }, [action, disabled, isActionPending, state]);
170211
171 if (!visible && !showArm64Warning) return null;212 void bridge
213 .checkForUpdate()
214 .then((result) => {
215 if (result.checked) return;
216 toastManager.add(
217 stackedThreadToast({
218 type: "error",
219 title: "Could not check for updates",
220 description:
221 result.state.message ?? "Automatic updates are not available in this build.",
222 }),
223 );
224 })
225 .catch((error) => {
226 toastManager.add(
227 stackedThreadToast({
228 type: "error",
229 title: "Could not check for updates",
230 description: error instanceof Error ? error.message : "Update check failed.",
231 }),
232 );
233 })
234 .finally(() => setIsActionPending(false));
235 }, [action, disabled, isActionPending, state]);
172236
173 return (237 return (
174 <div className="flex flex-col gap-1">238 <SidebarMenuItem className="ml-auto shrink-0">
175 {showArm64Warning && arm64Description && (239 <Tooltip>
176 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs">240 <TooltipTrigger
177 <TriangleAlertIcon />241 render={
178 <AlertTitle>Intel build on Apple Silicon</AlertTitle>242 <button
179 <AlertDescription>{arm64Description}</AlertDescription>243 type="button"
180 </Alert>244 aria-label={tooltip}
181 )}245 aria-disabled={disabled || isActionPending || undefined}
182 {visible && (246 disabled={disabled || isActionPending}
183 <div247 className={cn(
184 className={`group/update relative flex h-7 w-full items-center rounded-lg bg-update-surface text-xs font-medium text-update-foreground ${248 "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60",
185 disabled ? " cursor-not-allowed opacity-60" : ""249 isUpdateState
186 }`}250 ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12"
187 >251 : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground",
188 <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-update/12" />252 )}
189 <Tooltip>253 onClick={handleAction}
190 <TooltipTrigger
191 render={
192 <button
193 type="button"
194 aria-label={tooltip}
195 aria-disabled={disabled || isActionPending || undefined}
196 disabled={disabled || isActionPending}
197 className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer"
198 onClick={handleAction}
199 >
200 {action === "install" ? (
201 <>
202 <RotateCwIcon className="size-3.5" />
203 <span>Restart to update</span>
204 </>
205 ) : state?.status === "downloading" ? (
206 <>
207 <DownloadIcon className="size-3.5" />
208 <span>
209 Downloading
210 {typeof state.downloadPercent === "number"
211 ? ` (${Math.floor(state.downloadPercent)}%)`
212 : "…"}
213 </span>
214 </>
215 ) : (
216 <>
217 <DownloadIcon className="size-3.5" />
218 <span>Update available</span>
219 </>
220 )}
221 </button>
222 }
223 />
224 <TooltipPopup
225 align="start"
226 className={
227 state?.channel === "nightly" && state.releaseNotes.length > 0
228 ? // pointer-events-auto overrides the positioner's pointer-events-none so the
229 // release notes stay open (and scrollable) when the cursor moves into them.
230 "pointer-events-auto max-w-none text-balance"
231 : undefined
232 }
233 side="top"
234 >254 >
235 {state ? (255 {action === "install" ? (
236 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />256 <RotateCwIcon className="size-4" />
257 ) : isUpdateState ? (
258 <DownloadIcon className="size-4" />
237 ) : (259 ) : (
238 tooltip260 <RefreshCwIcon
261 className={cn("size-4", state?.status === "checking" && "animate-spin")}
262 />
239 )}263 )}
240 </TooltipPopup>264 </button>
241 </Tooltip>265 }
242 {action === "download" && (266 />
243 <Tooltip>267 <TooltipPopup
244 <TooltipTrigger268 align="center"
245 render={269 className={
246 <button270 isUpdateState && state?.channel === "nightly" && state.releaseNotes.length > 0
247 type="button"271 ? // pointer-events-auto overrides the positioner's pointer-events-none so the
248 aria-label="Dismiss update"272 // release notes stay open (and scrollable) when the cursor moves into them.
249 className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-update-foreground transition-colors"273 "pointer-events-auto max-w-none text-balance"
250 onClick={() => setDismissed(true)}274 : undefined
251 >275 }
252 <XIcon className="size-3.5" />276 side="top"
253 </button>277 style={
278 isUpdateState
279 ? {
280 background:
281 "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",
282 borderColor: "var(--update-foreground)",
254 }283 }
255 />284 : undefined
256 <TooltipPopup side="top">Dismiss until next launch</TooltipPopup>285 }
257 </Tooltip>286 variant={isUpdateState ? "glass" : "default"}
287 >
288 {isUpdateState && state ? (
289 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
290 ) : (
291 tooltip
258 )}292 )}
259 </div>293 </TooltipPopup>
260 )}294 </Tooltip>
261 </div>295 </SidebarMenuItem>
262 );296 );
263}297}