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 {
55 SettingsIcon,
66} from "lucide-react";
77import { memo, useCallback } from "react";
8import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
8import { Link, useLocation, useNavigate } from "@tanstack/react-router";
99
1010import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
1111import { cn } from "../../lib/utils";
......@@ -27,8 +27,9 @@ import {
2727 SidebarTrigger,
2828 useSidebar,
2929} from "../ui/sidebar";
30import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3031import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
31import { SidebarUpdatePill } from "./SidebarUpdatePill";
32import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
3233
3334export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3435 isElectron,
......@@ -119,7 +120,6 @@ function T3Wordmark() {
119120export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
120121 const navigate = useNavigate();
121122 const { isMobile, setOpenMobile } = useSidebar();
122 const canGoBack = useCanGoBack();
123123 const currentFooterPage = useLocation({
124124 select: (location) =>
125125 location.pathname === "/usage"
......@@ -154,54 +154,72 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
154154
155155 const handleBackClick = useCallback(() => {
156156 closeMobileSidebar();
157 if (canGoBack) {
158 window.history.back();
159 return;
160 }
161157 void navigate({ to: "/" });
162 }, [canGoBack, closeMobileSidebar, navigate]);
158 }, [closeMobileSidebar, navigate]);
163159
164160 return (
165161 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
166162 <SidebarProviderUpdatePill />
167 <SidebarUpdatePill />
168 <SidebarMenu>
169 {currentFooterPage === "pull-requests" ? (
170 <SidebarMenuItem>
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>
163 <SidebarUpdateArchitectureWarning />
164 <SidebarMenu className="flex-row items-center">
165 {currentFooterPage ? (
166 <SidebarMenuItem className="min-w-0 flex-1">
186167 <SidebarMenuButton onClick={handleBackClick}>
187168 <ArrowLeftIcon />
188169 <span>Back</span>
189170 </SidebarMenuButton>
190171 </SidebarMenuItem>
191172 ) : (
192 <SidebarMenuItem>
193 <SidebarMenuButton onClick={handleUsageClick}>
194 <ChartNoAxesColumnIcon />
195 <span>Usage</span>
196 </SidebarMenuButton>
197 </SidebarMenuItem>
173 <>
174 <SidebarMenuItem className="shrink-0">
175 <Tooltip>
176 <TooltipTrigger
177 render={
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 </>
198221 )}
199 <SidebarMenuItem>
200 <SidebarMenuButton onClick={handleSettingsClick}>
201 <SettingsIcon />
202 <span>Settings</span>
203 </SidebarMenuButton>
204 </SidebarMenuItem>
222 <SidebarUpdatePill />
205223 </SidebarMenu>
206224 </SidebarFooter>
207225 );
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+133-99
......@@ -1,10 +1,12 @@
1import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";
1import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
22import { useCallback, useState } from "react";
33import { isElectron } from "../../env";
4import { cn } from "../../lib/utils";
45import { ensureLocalApi } from "../../localApi";
56import { useDesktopUpdateState } from "../../state/desktopUpdate";
67import { stackedThreadToast, toastManager } from "../ui/toast";
78import {
9 canCheckForUpdate,
810 getArm64IntelBuildWarningDescription,
911 getDesktopUpdateActionError,
1012 getDesktopUpdateButtonTooltip,
......@@ -12,12 +14,12 @@ import {
1214 isDesktopUpdateButtonDisabled,
1315 resolveDesktopUpdateButtonAction,
1416 shouldShowArm64IntelBuildWarning,
15 shouldShowDesktopUpdateButton,
1617 shouldToastDesktopUpdateActionResult,
1718} from "../desktopUpdate.logic";
1819import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
1920import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
2021import { Separator } from "../ui/separator";
22import { SidebarMenuItem } from "../ui/sidebar";
2123import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2224
2325function keyReleaseNoteItems(items: ReadonlyArray<string>) {
......@@ -41,16 +43,29 @@ function SidebarUpdateReleaseNotesTooltip({
4143 }
4244
4345 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">
4547 <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 )}
4762 </div>
4863 <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">
4964 {state.releaseNotes.map((releaseNote, index) => (
5065 <div key={releaseNote.version}>
5166 {index > 0 && <Separator className="my-3 bg-border/60" />}
5267 <section>
53 <h3 className="text-muted-foreground text-xs leading-4 font-semibold">
68 <h3 className="text-foreground text-xs leading-4 font-semibold">
5469 {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
5570 </h3>
5671 <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">
......@@ -68,24 +83,50 @@ function SidebarUpdateReleaseNotesTooltip({
6883 );
6984}
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
71106export function SidebarUpdatePill() {
107 return isElectron ? <SidebarUpdateControl /> : null;
108}
109
110function SidebarUpdateControl() {
72111 const state = useDesktopUpdateState();
73 const [dismissed, setDismissed] = useState(false);
74112 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);
79114 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
80
81 const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state);
82 const arm64Description =
83 state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null;
115 const isDownloading = state?.status === "downloading";
116 const isUpdateState = action !== "none" || isDownloading;
117 const tooltip = isUpdateState
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
85126 const handleAction = useCallback(async () => {
86127 const bridge = window.desktopBridge;
87128 if (!bridge || !state) return;
88 if (disabled || action === "none" || isActionPending) return;
129 if (disabled || isActionPending) return;
89130
90131 setIsActionPending(true);
91132
......@@ -165,99 +206,92 @@ export function SidebarUpdatePill() {
165206 );
166207 })
167208 .finally(() => setIsActionPending(false));
209 return;
168210 }
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
173237 return (
174 <div className="flex flex-col gap-1">
175 {showArm64Warning && arm64Description && (
176 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs">
177 <TriangleAlertIcon />
178 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
179 <AlertDescription>{arm64Description}</AlertDescription>
180 </Alert>
181 )}
182 {visible && (
183 <div
184 className={`group/update relative flex h-7 w-full items-center rounded-lg bg-update-surface text-xs font-medium text-update-foreground ${
185 disabled ? " cursor-not-allowed opacity-60" : ""
186 }`}
187 >
188 <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-update/12" />
189 <Tooltip>
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"
238 <SidebarMenuItem className="ml-auto shrink-0">
239 <Tooltip>
240 <TooltipTrigger
241 render={
242 <button
243 type="button"
244 aria-label={tooltip}
245 aria-disabled={disabled || isActionPending || undefined}
246 disabled={disabled || isActionPending}
247 className={cn(
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",
249 isUpdateState
250 ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12"
251 : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground",
252 )}
253 onClick={handleAction}
234254 >
235 {state ? (
236 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
255 {action === "install" ? (
256 <RotateCwIcon className="size-4" />
257 ) : isUpdateState ? (
258 <DownloadIcon className="size-4" />
237259 ) : (
238 tooltip
260 <RefreshCwIcon
261 className={cn("size-4", state?.status === "checking" && "animate-spin")}
262 />
239263 )}
240 </TooltipPopup>
241 </Tooltip>
242 {action === "download" && (
243 <Tooltip>
244 <TooltipTrigger
245 render={
246 <button
247 type="button"
248 aria-label="Dismiss update"
249 className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-update-foreground transition-colors"
250 onClick={() => setDismissed(true)}
251 >
252 <XIcon className="size-3.5" />
253 </button>
264 </button>
265 }
266 />
267 <TooltipPopup
268 align="center"
269 className={
270 isUpdateState && state?.channel === "nightly" && state.releaseNotes.length > 0
271 ? // pointer-events-auto overrides the positioner's pointer-events-none so the
272 // release notes stay open (and scrollable) when the cursor moves into them.
273 "pointer-events-auto max-w-none text-balance"
274 : undefined
275 }
276 side="top"
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)",
254283 }
255 />
256 <TooltipPopup side="top">Dismiss until next launch</TooltipPopup>
257 </Tooltip>
284 : undefined
285 }
286 variant={isUpdateState ? "glass" : "default"}
287 >
288 {isUpdateState && state ? (
289 <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
290 ) : (
291 tooltip
258292 )}
259 </div>
260 )}
261 </div>
293 </TooltipPopup>
294 </Tooltip>
295 </SidebarMenuItem>
262296 );
263297}