authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-03-29 09:53:10+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-29 04:23:10+00:00
logf82bae16680206e4be20baf3d835941aa68611cf
tree91eddb476206554a07a0c0687f68d3040809523d
parent9e60597174dcd69ae405d3966a374213c745388a
signature Signed by PGP key B5690EEEBB952194

Update system overhaul (#1505)

Co-authored-by: Jono Kemball <jono-kemball@idexx.com> Co-authored-by: Ariaj Sarkar <rajsarkar02205@gmail.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

11 files changed, 575 insertions(+), 83 deletions(-)

apps/desktop/src/main.ts+22-3
......@@ -20,6 +20,7 @@ import * as Effect from "effect/Effect";
2020import type {
2121 DesktopTheme,
2222 DesktopUpdateActionResult,
23 DesktopUpdateCheckResult,
2324 DesktopUpdateState,
2425} from "@t3tools/contracts";
2526import { autoUpdater } from "electron-updater";
......@@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
5657const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
5758const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
5859const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
60const UPDATE_CHECK_CHANNEL = "desktop:update-check";
5961const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
6062const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6163const STATE_DIR = Path.join(BASE_DIR, "userdata");
......@@ -756,13 +758,13 @@ function shouldEnableAutoUpdates(): boolean {
756758 );
757759}
758760
759async function checkForUpdates(reason: string): Promise<void> {
760 if (isQuitting || !updaterConfigured || updateCheckInFlight) return;
761async function checkForUpdates(reason: string): Promise<boolean> {
762 if (isQuitting || !updaterConfigured || updateCheckInFlight) return false;
761763 if (updateState.status === "downloading" || updateState.status === "downloaded") {
762764 console.info(
763765 `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
764766 );
765 return;
767 return false;
766768 }
767769 updateCheckInFlight = true;
768770 setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString()));
......@@ -770,12 +772,14 @@ async function checkForUpdates(reason: string): Promise<void> {
770772
771773 try {
772774 await autoUpdater.checkForUpdates();
775 return true;
773776 } catch (error: unknown) {
774777 const message = error instanceof Error ? error.message : String(error);
775778 setUpdateState(
776779 reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
777780 );
778781 console.error(`[desktop-updater] Failed to check for updates: ${message}`);
782 return true;
779783 } finally {
780784 updateCheckInFlight = false;
781785 }
......@@ -1263,6 +1267,21 @@ function registerIpcHandlers(): void {
12631267 state: updateState,
12641268 } satisfies DesktopUpdateActionResult;
12651269 });
1270
1271 ipcMain.removeHandler(UPDATE_CHECK_CHANNEL);
1272 ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => {
1273 if (!updaterConfigured) {
1274 return {
1275 checked: false,
1276 state: updateState,
1277 } satisfies DesktopUpdateCheckResult;
1278 }
1279 const checked = await checkForUpdates("web-ui");
1280 return {
1281 checked,
1282 state: updateState,
1283 } satisfies DesktopUpdateCheckResult;
1284 });
12661285}
12671286
12681287function getIconOption(): { icon: string } | Record<string, never> {
apps/desktop/src/preload.ts+2
......@@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
99const MENU_ACTION_CHANNEL = "desktop:menu-action";
1010const UPDATE_STATE_CHANNEL = "desktop:update-state";
1111const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1213const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1314const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1415const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
......@@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
3536 };
3637 },
3738 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
39 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
3840 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
3941 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
4042 onUpdateState: (listener) => {
apps/web/src/components/Sidebar.tsx+10-45
......@@ -5,7 +5,6 @@ import {
55 FolderIcon,
66 GitPullRequestIcon,
77 PlusIcon,
8 RocketIcon,
98 SettingsIcon,
109 SquarePenIcon,
1110 TerminalIcon,
......@@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
7877import {
7978 getArm64IntelBuildWarningDescription,
8079 getDesktopUpdateActionError,
81 getDesktopUpdateButtonTooltip,
80 getDesktopUpdateInstallConfirmationMessage,
8281 isDesktopUpdateButtonDisabled,
8382 resolveDesktopUpdateButtonAction,
8483 shouldShowArm64IntelBuildWarning,
85 shouldHighlightDesktopUpdateError,
86 shouldShowDesktopUpdateButton,
8784 shouldToastDesktopUpdateActionResult,
8885} from "./desktopUpdate.logic";
8986import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
......@@ -120,6 +117,7 @@ import {
120117 sortProjectsForSidebar,
121118 sortThreadsForSidebar,
122119} from "./Sidebar.logic";
120import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
123121import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
124122import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
125123
......@@ -1661,12 +1659,6 @@ export default function Sidebar() {
16611659 };
16621660 }, []);
16631661
1664 const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState);
1665
1666 const desktopUpdateTooltip = desktopUpdateState
1667 ? getDesktopUpdateButtonTooltip(desktopUpdateState)
1668 : "Update available";
1669
16701662 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
16711663 const desktopUpdateButtonAction = desktopUpdateState
16721664 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
......@@ -1677,17 +1669,6 @@ export default function Sidebar() {
16771669 desktopUpdateState && showArm64IntelBuildWarning
16781670 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
16791671 : null;
1680 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
1681 ? "cursor-not-allowed opacity-60"
1682 : "hover:bg-accent hover:text-foreground";
1683 const desktopUpdateButtonClasses =
1684 desktopUpdateState?.status === "downloaded"
1685 ? "text-emerald-500"
1686 : desktopUpdateState?.status === "downloading"
1687 ? "text-sky-400"
1688 : shouldHighlightDesktopUpdateError(desktopUpdateState)
1689 ? "text-rose-500 animate-pulse"
1690 : "text-amber-500 animate-pulse";
16911672 const newThreadShortcutLabel =
16921673 shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
16931674 shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
......@@ -1728,6 +1709,10 @@ export default function Sidebar() {
17281709 }
17291710
17301711 if (desktopUpdateButtonAction === "install") {
1712 const confirmed = window.confirm(
1713 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState),
1714 );
1715 if (!confirmed) return;
17311716 void bridge
17321717 .installUpdate()
17331718 .then((result) => {
......@@ -1795,30 +1780,9 @@ export default function Sidebar() {
17951780 return (
17961781 <>
17971782 {isElectron ? (
1798 <>
1799 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
1800 {wordmark}
1801 {showDesktopUpdateButton && (
1802 <Tooltip>
1803 <TooltipTrigger
1804 render={
1805 <button
1806 type="button"
1807 aria-label={desktopUpdateTooltip}
1808 aria-disabled={desktopUpdateButtonDisabled || undefined}
1809 disabled={desktopUpdateButtonDisabled}
1810 className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
1811 onClick={handleDesktopUpdateButtonClick}
1812 >
1813 <RocketIcon className="size-3.5" />
1814 </button>
1815 }
1816 />
1817 <TooltipPopup side="bottom">{desktopUpdateTooltip}</TooltipPopup>
1818 </Tooltip>
1819 )}
1820 </SidebarHeader>
1821 </>
1783 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
1784 {wordmark}
1785 </SidebarHeader>
18221786 ) : (
18231787 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
18241788 {wordmark}
......@@ -2006,6 +1970,7 @@ export default function Sidebar() {
20061970
20071971 <SidebarSeparator />
20081972 <SidebarFooter className="p-2">
1973 <SidebarUpdatePill />
20091974 <SidebarMenu>
20101975 <SidebarMenuItem>
20111976 <SidebarMenuButton
apps/web/src/components/desktopUpdate.logic.test.ts+95-20
......@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
22import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
33
44import {
5 canCheckForUpdate,
56 getArm64IntelBuildWarningDescription,
67 getDesktopUpdateActionError,
78 getDesktopUpdateButtonTooltip,
9 getDesktopUpdateInstallConfirmationMessage,
810 isDesktopUpdateButtonDisabled,
911 resolveDesktopUpdateButtonAction,
10 shouldHighlightDesktopUpdateError,
1112 shouldShowArm64IntelBuildWarning,
1213 shouldShowDesktopUpdateButton,
1314 shouldToastDesktopUpdateActionResult,
......@@ -69,6 +70,16 @@ describe("desktop update button state", () => {
6970 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");
7071 });
7172
73 it("prefers install when a downloaded version already exists", () => {
74 const state: DesktopUpdateState = {
75 ...baseState,
76 status: "available",
77 availableVersion: "1.1.0",
78 downloadedVersion: "1.1.0",
79 };
80 expect(resolveDesktopUpdateButtonAction(state)).toBe("install");
81 });
82
7283 it("hides the button for non-actionable check errors", () => {
7384 const state: DesktopUpdateState = {
7485 ...baseState,
......@@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => {
169180 ).toBe(false);
170181 });
171182
172 it("highlights only actionable updater errors", () => {
173 expect(
174 shouldHighlightDesktopUpdateError({
175 ...baseState,
176 status: "error",
177 errorContext: "download",
178 canRetry: true,
179 }),
180 ).toBe(true);
181 expect(
182 shouldHighlightDesktopUpdateError({
183 ...baseState,
184 status: "error",
185 errorContext: "check",
186 canRetry: true,
187 }),
188 ).toBe(false);
189 });
190
191183 it("shows an Apple Silicon warning for Intel builds under Rosetta", () => {
192184 const state: DesktopUpdateState = {
193185 ...baseState,
......@@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => {
213205
214206 expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
215207 });
208
209 it("includes the downloaded version in the install confirmation copy", () => {
210 expect(
211 getDesktopUpdateInstallConfirmationMessage({
212 availableVersion: "1.1.0",
213 downloadedVersion: "1.1.1",
214 }),
215 ).toContain("Install update 1.1.1 and restart T3 Code?");
216 });
217
218 it("falls back to generic install confirmation copy when no version is available", () => {
219 expect(
220 getDesktopUpdateInstallConfirmationMessage({
221 availableVersion: null,
222 downloadedVersion: null,
223 }),
224 ).toContain("Install update and restart T3 Code?");
225 });
226});
227
228describe("canCheckForUpdate", () => {
229 it("returns false for null state", () => {
230 expect(canCheckForUpdate(null)).toBe(false);
231 });
232
233 it("returns false when updates are disabled", () => {
234 expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false);
235 });
236
237 it("returns false while checking", () => {
238 expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false);
239 });
240
241 it("returns false while downloading", () => {
242 expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe(
243 false,
244 );
245 });
246
247 it("returns false once an update has been downloaded", () => {
248 expect(
249 canCheckForUpdate({
250 ...baseState,
251 status: "downloaded",
252 availableVersion: "1.1.0",
253 downloadedVersion: "1.1.0",
254 }),
255 ).toBe(false);
256 });
257
258 it("returns true when idle", () => {
259 expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true);
260 });
261
262 it("returns true when up-to-date", () => {
263 expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true);
264 });
265
266 it("returns true when an update is available", () => {
267 expect(
268 canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }),
269 ).toBe(true);
270 });
271
272 it("returns true on error so the user can retry", () => {
273 expect(
274 canCheckForUpdate({
275 ...baseState,
276 status: "error",
277 errorContext: "check",
278 message: "network",
279 }),
280 ).toBe(true);
281 });
282});
283
284describe("getDesktopUpdateButtonTooltip", () => {
285 it("returns 'Up to date' for non-actionable states", () => {
286 expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date");
287 expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe(
288 "Up to date",
289 );
290 });
216291});
apps/web/src/components/desktopUpdate.logic.ts+21-7
......@@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none";
55export function resolveDesktopUpdateButtonAction(
66 state: DesktopUpdateState,
77): DesktopUpdateButtonAction {
8 if (state.downloadedVersion) {
9 return "install";
10 }
811 if (state.status === "available") {
912 return "download";
1013 }
11 if (state.status === "downloaded") {
12 return "install";
13 }
1414 if (state.status === "error") {
15 if (state.errorContext === "install" && state.downloadedVersion) {
16 return "install";
17 }
1815 if (state.errorContext === "download" && state.availableVersion) {
1916 return "download";
2017 }
......@@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
7673 }
7774 return state.message ?? "Update failed";
7875 }
79 return "Update available";
76 return "Up to date";
77}
78
79export function getDesktopUpdateInstallConfirmationMessage(
80 state: Pick<DesktopUpdateState, "availableVersion" | "downloadedVersion">,
81): string {
82 const version = state.downloadedVersion ?? state.availableVersion;
83 return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`;
8084}
8185
8286export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null {
......@@ -94,3 +98,13 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu
9498 if (!state || state.status !== "error") return false;
9599 return state.errorContext === "download" || state.errorContext === "install";
96100}
101
102export function canCheckForUpdate(state: DesktopUpdateState | null): boolean {
103 if (!state || !state.enabled) return false;
104 return (
105 state.status !== "checking" &&
106 state.status !== "downloading" &&
107 state.status !== "downloaded" &&
108 state.status !== "disabled"
109 );
110}
apps/web/src/components/settings/SettingsPanels.tsx+151-7
......@@ -22,12 +22,24 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
2222import { normalizeModelSlug } from "@t3tools/shared/model";
2323import { Equal } from "effect";
2424import { APP_VERSION } from "../../branding";
25import {
26 canCheckForUpdate,
27 getDesktopUpdateButtonTooltip,
28 getDesktopUpdateInstallConfirmationMessage,
29 isDesktopUpdateButtonDisabled,
30 resolveDesktopUpdateButtonAction,
31} from "../../components/desktopUpdate.logic";
2532import { ProviderModelPicker } from "../chat/ProviderModelPicker";
2633import { TraitsPicker } from "../chat/TraitsPicker";
2734import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
35import { isElectron } from "../../env";
2836import { useTheme } from "../../hooks/useTheme";
2937import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
3038import { useThreadActions } from "../../hooks/useThreadActions";
39import {
40 setDesktopUpdateStateQueryData,
41 useDesktopUpdateState,
42} from "../../lib/desktopUpdateReactQuery";
3143import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery";
3244import {
3345 MAX_CUSTOM_MODEL_LENGTH,
......@@ -236,7 +248,7 @@ function SettingsRow({
236248 control,
237249 children,
238250}: {
239 title: string;
251 title: ReactNode;
240252 description: string;
241253 status?: ReactNode;
242254 resetAction?: ReactNode;
......@@ -299,6 +311,133 @@ function SettingsPageContainer({ children }: { children: ReactNode }) {
299311 );
300312}
301313
314function AboutVersionTitle() {
315 return (
316 <span className="inline-flex items-center gap-2">
317 <span>Version</span>
318 <code className="text-[11px] font-medium text-muted-foreground">{APP_VERSION}</code>
319 </span>
320 );
321}
322
323function AboutVersionSection() {
324 const queryClient = useQueryClient();
325 const updateStateQuery = useDesktopUpdateState();
326
327 const updateState = updateStateQuery.data ?? null;
328
329 const handleButtonClick = useCallback(() => {
330 const bridge = window.desktopBridge;
331 if (!bridge) return;
332
333 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
334
335 if (action === "download") {
336 void bridge
337 .downloadUpdate()
338 .then((result) => {
339 setDesktopUpdateStateQueryData(queryClient, result.state);
340 })
341 .catch((error: unknown) => {
342 toastManager.add({
343 type: "error",
344 title: "Could not download update",
345 description: error instanceof Error ? error.message : "Download failed.",
346 });
347 });
348 return;
349 }
350
351 if (action === "install") {
352 const confirmed = window.confirm(
353 getDesktopUpdateInstallConfirmationMessage(
354 updateState ?? { availableVersion: null, downloadedVersion: null },
355 ),
356 );
357 if (!confirmed) return;
358 void bridge
359 .installUpdate()
360 .then((result) => {
361 setDesktopUpdateStateQueryData(queryClient, result.state);
362 })
363 .catch((error: unknown) => {
364 toastManager.add({
365 type: "error",
366 title: "Could not install update",
367 description: error instanceof Error ? error.message : "Install failed.",
368 });
369 });
370 return;
371 }
372
373 if (typeof bridge.checkForUpdate !== "function") return;
374 void bridge
375 .checkForUpdate()
376 .then((result) => {
377 setDesktopUpdateStateQueryData(queryClient, result.state);
378 if (!result.checked) {
379 toastManager.add({
380 type: "error",
381 title: "Could not check for updates",
382 description:
383 result.state.message ?? "Automatic updates are not available in this build.",
384 });
385 }
386 })
387 .catch((error: unknown) => {
388 toastManager.add({
389 type: "error",
390 title: "Could not check for updates",
391 description: error instanceof Error ? error.message : "Update check failed.",
392 });
393 });
394 }, [queryClient, updateState]);
395
396 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
397 const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null;
398 const buttonDisabled =
399 action === "none"
400 ? !canCheckForUpdate(updateState)
401 : isDesktopUpdateButtonDisabled(updateState);
402
403 const actionLabel: Record<string, string> = { download: "Download", install: "Install" };
404 const statusLabel: Record<string, string> = {
405 checking: "Checking…",
406 downloading: "Downloading…",
407 "up-to-date": "Up to Date",
408 };
409 const buttonLabel =
410 actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates";
411 const description =
412 action === "download" || action === "install"
413 ? "Update available."
414 : "Current version of the application.";
415
416 return (
417 <SettingsRow
418 title={<AboutVersionTitle />}
419 description={description}
420 control={
421 <Tooltip>
422 <TooltipTrigger
423 render={
424 <Button
425 size="xs"
426 variant={action === "install" ? "default" : "outline"}
427 disabled={buttonDisabled}
428 onClick={handleButtonClick}
429 >
430 {buttonLabel}
431 </Button>
432 }
433 />
434 {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null}
435 </Tooltip>
436 }
437 />
438 );
439}
440
302441export function useSettingsRestore(onRestored?: () => void) {
303442 const { theme, setTheme } = useTheme();
304443 const settings = useSettings();
......@@ -1258,12 +1397,17 @@ export function GeneralSettingsPanel() {
12581397 </Button>
12591398 }
12601399 />
1400 </SettingsSection>
12611401
1262 <SettingsRow
1263 title="Version"
1264 description="Current application version."
1265 control={<code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>}
1266 />
1402 <SettingsSection title="About">
1403 {isElectron ? (
1404 <AboutVersionSection />
1405 ) : (
1406 <SettingsRow
1407 title={<AboutVersionTitle />}
1408 description="Current version of the application."
1409 />
1410 )}
12671411 </SettingsSection>
12681412 </SettingsPageContainer>
12691413 );
......@@ -1325,7 +1469,7 @@ export function ArchivedThreadsPanel() {
13251469 <SettingsPageContainer>
13261470 {archivedGroups.length === 0 ? (
13271471 <SettingsSection title="Archived threads">
1328 <Empty className="min-h-[22rem]">
1472 <Empty className="min-h-88">
13291473 <EmptyMedia variant="icon">
13301474 <ArchiveIcon />
13311475 </EmptyMedia>
apps/web/src/components/settings/SettingsSidebarNav.tsx+1-1
......@@ -20,7 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
2020 icon: ComponentType<{ className?: string }>;
2121}> = [
2222 { label: "General", to: "/settings/general", icon: Settings2Icon },
23 { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon },
23 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
2424];
2525
2626export function SettingsSidebarNav({ pathname }: { pathname: string }) {
apps/web/src/components/sidebar/SidebarUpdatePill.tsx created+176
......@@ -0,0 +1,176 @@
1import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";
2import { useQueryClient } from "@tanstack/react-query";
3import { useCallback, useState } from "react";
4import { isElectron } from "../../env";
5import {
6 setDesktopUpdateStateQueryData,
7 useDesktopUpdateState,
8} from "../../lib/desktopUpdateReactQuery";
9import { toastManager } from "../ui/toast";
10import {
11 getArm64IntelBuildWarningDescription,
12 getDesktopUpdateActionError,
13 getDesktopUpdateButtonTooltip,
14 getDesktopUpdateInstallConfirmationMessage,
15 isDesktopUpdateButtonDisabled,
16 resolveDesktopUpdateButtonAction,
17 shouldShowArm64IntelBuildWarning,
18 shouldShowDesktopUpdateButton,
19 shouldToastDesktopUpdateActionResult,
20} from "../desktopUpdate.logic";
21import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23
24export function SidebarUpdatePill() {
25 const queryClient = useQueryClient();
26 const state = useDesktopUpdateState().data ?? null;
27 const [dismissed, setDismissed] = useState(false);
28
29 const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed;
30 const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available";
31 const disabled = isDesktopUpdateButtonDisabled(state);
32 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
33
34 const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state);
35 const arm64Description =
36 state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null;
37
38 const handleAction = useCallback(() => {
39 const bridge = window.desktopBridge;
40 if (!bridge || !state) return;
41 if (disabled || action === "none") return;
42
43 if (action === "download") {
44 void bridge
45 .downloadUpdate()
46 .then((result) => {
47 setDesktopUpdateStateQueryData(queryClient, result.state);
48 if (result.completed) {
49 toastManager.add({
50 type: "success",
51 title: "Update downloaded",
52 description: "Restart the app from the update button to install it.",
53 });
54 }
55 if (!shouldToastDesktopUpdateActionResult(result)) return;
56 const actionError = getDesktopUpdateActionError(result);
57 if (!actionError) return;
58 toastManager.add({
59 type: "error",
60 title: "Could not download update",
61 description: actionError,
62 });
63 })
64 .catch((error) => {
65 toastManager.add({
66 type: "error",
67 title: "Could not start update download",
68 description: error instanceof Error ? error.message : "An unexpected error occurred.",
69 });
70 });
71 return;
72 }
73
74 if (action === "install") {
75 const confirmed = window.confirm(getDesktopUpdateInstallConfirmationMessage(state));
76 if (!confirmed) return;
77 void bridge
78 .installUpdate()
79 .then((result) => {
80 setDesktopUpdateStateQueryData(queryClient, result.state);
81 if (!shouldToastDesktopUpdateActionResult(result)) return;
82 const actionError = getDesktopUpdateActionError(result);
83 if (!actionError) return;
84 toastManager.add({
85 type: "error",
86 title: "Could not install update",
87 description: actionError,
88 });
89 })
90 .catch((error) => {
91 toastManager.add({
92 type: "error",
93 title: "Could not install update",
94 description: error instanceof Error ? error.message : "An unexpected error occurred.",
95 });
96 });
97 }
98 }, [action, disabled, queryClient, state]);
99
100 if (!visible && !showArm64Warning) return null;
101
102 return (
103 <div className="flex flex-col gap-1">
104 {showArm64Warning && arm64Description && (
105 <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs">
106 <TriangleAlertIcon />
107 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
108 <AlertDescription>{arm64Description}</AlertDescription>
109 </Alert>
110 )}
111 {visible && (
112 <div
113 className={`group/update relative flex h-7 w-full items-center rounded-lg bg-primary/15 text-xs font-medium text-primary ${
114 disabled ? " cursor-not-allowed opacity-60" : ""
115 }`}
116 >
117 <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-primary/22" />
118 <Tooltip>
119 <TooltipTrigger
120 render={
121 <button
122 type="button"
123 aria-label={tooltip}
124 aria-disabled={disabled || undefined}
125 disabled={disabled}
126 className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer"
127 onClick={handleAction}
128 >
129 {action === "install" ? (
130 <>
131 <RotateCwIcon className="size-3.5" />
132 <span>Restart to update</span>
133 </>
134 ) : state?.status === "downloading" ? (
135 <>
136 <DownloadIcon className="size-3.5" />
137 <span>
138 Downloading
139 {typeof state.downloadPercent === "number"
140 ? ` (${Math.floor(state.downloadPercent)}%)`
141 : "…"}
142 </span>
143 </>
144 ) : (
145 <>
146 <DownloadIcon className="size-3.5" />
147 <span>Update available</span>
148 </>
149 )}
150 </button>
151 }
152 />
153 <TooltipPopup side="top">{tooltip}</TooltipPopup>
154 </Tooltip>
155 {action === "download" && (
156 <Tooltip>
157 <TooltipTrigger
158 render={
159 <button
160 type="button"
161 aria-label="Dismiss update"
162 className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-primary/60 transition-colors hover:text-primary"
163 onClick={() => setDismissed(true)}
164 >
165 <XIcon className="size-3.5" />
166 </button>
167 }
168 />
169 <TooltipPopup side="top">Dismiss until next launch</TooltipPopup>
170 </Tooltip>
171 )}
172 </div>
173 )}
174 </div>
175 );
176}
apps/web/src/lib/desktopUpdateReactQuery.test.ts created+49
......@@ -0,0 +1,49 @@
1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it } from "vitest";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4import {
5 desktopUpdateQueryKeys,
6 desktopUpdateStateQueryOptions,
7 setDesktopUpdateStateQueryData,
8} from "./desktopUpdateReactQuery";
9
10const baseState: DesktopUpdateState = {
11 enabled: true,
12 status: "idle",
13 currentVersion: "1.0.0",
14 hostArch: "x64",
15 appArch: "x64",
16 runningUnderArm64Translation: false,
17 availableVersion: null,
18 downloadedVersion: null,
19 downloadPercent: null,
20 checkedAt: null,
21 message: null,
22 errorContext: null,
23 canRetry: false,
24};
25
26describe("desktopUpdateStateQueryOptions", () => {
27 it("always refetches on mount so Settings does not reuse stale desktop update state", () => {
28 const options = desktopUpdateStateQueryOptions();
29
30 expect(options.staleTime).toBe(Infinity);
31 expect(options.refetchOnMount).toBe("always");
32 });
33});
34
35describe("setDesktopUpdateStateQueryData", () => {
36 it("writes desktop update state into the shared cache key", () => {
37 const queryClient = new QueryClient();
38 const nextState: DesktopUpdateState = {
39 ...baseState,
40 status: "downloaded",
41 availableVersion: "1.1.0",
42 downloadedVersion: "1.1.0",
43 };
44
45 setDesktopUpdateStateQueryData(queryClient, nextState);
46
47 expect(queryClient.getQueryData(desktopUpdateQueryKeys.state())).toEqual(nextState);
48 });
49});
apps/web/src/lib/desktopUpdateReactQuery.ts created+42
......@@ -0,0 +1,42 @@
1import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
2import { useEffect } from "react";
3import type { DesktopUpdateState } from "@t3tools/contracts";
4
5export const desktopUpdateQueryKeys = {
6 all: ["desktop", "update"] as const,
7 state: () => ["desktop", "update", "state"] as const,
8};
9
10export const setDesktopUpdateStateQueryData = (
11 queryClient: QueryClient,
12 state: DesktopUpdateState | null,
13) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state);
14
15export function desktopUpdateStateQueryOptions() {
16 return queryOptions({
17 queryKey: desktopUpdateQueryKeys.state(),
18 queryFn: async () => {
19 const bridge = window.desktopBridge;
20 if (!bridge || typeof bridge.getUpdateState !== "function") return null;
21 return bridge.getUpdateState();
22 },
23 staleTime: Infinity,
24 refetchOnMount: "always",
25 });
26}
27
28export function useDesktopUpdateState() {
29 const queryClient = useQueryClient();
30 const query = useQuery(desktopUpdateStateQueryOptions());
31
32 useEffect(() => {
33 const bridge = window.desktopBridge;
34 if (!bridge || typeof bridge.onUpdateState !== "function") return;
35
36 return bridge.onUpdateState((nextState) => {
37 setDesktopUpdateStateQueryData(queryClient, nextState);
38 });
39 }, [queryClient]);
40
41 return query;
42}
packages/contracts/src/ipc.ts+6
......@@ -101,6 +101,11 @@ export interface DesktopUpdateActionResult {
101101 state: DesktopUpdateState;
102102}
103103
104export interface DesktopUpdateCheckResult {
105 checked: boolean;
106 state: DesktopUpdateState;
107}
108
104109export interface DesktopBridge {
105110 getWsUrl: () => string | null;
106111 pickFolder: () => Promise<string | null>;
......@@ -113,6 +118,7 @@ export interface DesktopBridge {
113118 openExternal: (url: string) => Promise<boolean>;
114119 onMenuAction: (listener: (action: string) => void) => () => void;
115120 getUpdateState: () => Promise<DesktopUpdateState>;
121 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;
116122 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
117123 installUpdate: () => Promise<DesktopUpdateActionResult>;
118124 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;