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";...@@ -20,6 +20,7 @@ import * as Effect from "effect/Effect";
20import type {20import type {
21 DesktopTheme,21 DesktopTheme,
22 DesktopUpdateActionResult,22 DesktopUpdateActionResult,
23 DesktopUpdateCheckResult,
23 DesktopUpdateState,24 DesktopUpdateState,
24} from "@t3tools/contracts";25} from "@t3tools/contracts";
25import { autoUpdater } from "electron-updater";26import { autoUpdater } from "electron-updater";
...@@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";...@@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
56const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";57const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
57const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";58const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
58const UPDATE_INSTALL_CHANNEL = "desktop:update-install";59const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
60const UPDATE_CHECK_CHANNEL = "desktop:update-check";
59const GET_WS_URL_CHANNEL = "desktop:get-ws-url";61const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
60const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");62const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
61const STATE_DIR = Path.join(BASE_DIR, "userdata");63const STATE_DIR = Path.join(BASE_DIR, "userdata");
...@@ -756,13 +758,13 @@ function shouldEnableAutoUpdates(): boolean {...@@ -756,13 +758,13 @@ function shouldEnableAutoUpdates(): boolean {
756 );758 );
757}759}
758760
759async function checkForUpdates(reason: string): Promise<void> {761async function checkForUpdates(reason: string): Promise<boolean> {
760 if (isQuitting || !updaterConfigured || updateCheckInFlight) return;762 if (isQuitting || !updaterConfigured || updateCheckInFlight) return false;
761 if (updateState.status === "downloading" || updateState.status === "downloaded") {763 if (updateState.status === "downloading" || updateState.status === "downloaded") {
762 console.info(764 console.info(
763 `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,765 `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
764 );766 );
765 return;767 return false;
766 }768 }
767 updateCheckInFlight = true;769 updateCheckInFlight = true;
768 setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString()));770 setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString()));
...@@ -770,12 +772,14 @@ async function checkForUpdates(reason: string): Promise<void> {...@@ -770,12 +772,14 @@ async function checkForUpdates(reason: string): Promise<void> {
770772
771 try {773 try {
772 await autoUpdater.checkForUpdates();774 await autoUpdater.checkForUpdates();
775 return true;
773 } catch (error: unknown) {776 } catch (error: unknown) {
774 const message = error instanceof Error ? error.message : String(error);777 const message = error instanceof Error ? error.message : String(error);
775 setUpdateState(778 setUpdateState(
776 reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),779 reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
777 );780 );
778 console.error(`[desktop-updater] Failed to check for updates: ${message}`);781 console.error(`[desktop-updater] Failed to check for updates: ${message}`);
782 return true;
779 } finally {783 } finally {
780 updateCheckInFlight = false;784 updateCheckInFlight = false;
781 }785 }
...@@ -1263,6 +1267,21 @@ function registerIpcHandlers(): void {...@@ -1263,6 +1267,21 @@ function registerIpcHandlers(): void {
1263 state: updateState,1267 state: updateState,
1264 } satisfies DesktopUpdateActionResult;1268 } satisfies DesktopUpdateActionResult;
1265 });1269 });
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 });
1266}1285}
12671286
1268function getIconOption(): { icon: string } | Record<string, never> {1287function getIconOption(): { icon: string } | Record<string, never> {
apps/desktop/src/preload.ts+2
...@@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";...@@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
9const MENU_ACTION_CHANNEL = "desktop:menu-action";9const MENU_ACTION_CHANNEL = "desktop:menu-action";
10const UPDATE_STATE_CHANNEL = "desktop:update-state";10const UPDATE_STATE_CHANNEL = "desktop:update-state";
11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_CHECK_CHANNEL = "desktop:update-check";
12const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";13const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
13const UPDATE_INSTALL_CHANNEL = "desktop:update-install";14const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
14const GET_WS_URL_CHANNEL = "desktop:get-ws-url";15const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
...@@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
35 };36 };
36 },37 },
37 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),38 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
39 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
38 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),40 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
39 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),41 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
40 onUpdateState: (listener) => {42 onUpdateState: (listener) => {
apps/web/src/components/Sidebar.tsx+10-45
...@@ -5,7 +5,6 @@ import {...@@ -5,7 +5,6 @@ import {
5 FolderIcon,5 FolderIcon,
6 GitPullRequestIcon,6 GitPullRequestIcon,
7 PlusIcon,7 PlusIcon,
8 RocketIcon,
9 SettingsIcon,8 SettingsIcon,
10 SquarePenIcon,9 SquarePenIcon,
11 TerminalIcon,10 TerminalIcon,
...@@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";...@@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
78import {77import {
79 getArm64IntelBuildWarningDescription,78 getArm64IntelBuildWarningDescription,
80 getDesktopUpdateActionError,79 getDesktopUpdateActionError,
81 getDesktopUpdateButtonTooltip,80 getDesktopUpdateInstallConfirmationMessage,
82 isDesktopUpdateButtonDisabled,81 isDesktopUpdateButtonDisabled,
83 resolveDesktopUpdateButtonAction,82 resolveDesktopUpdateButtonAction,
84 shouldShowArm64IntelBuildWarning,83 shouldShowArm64IntelBuildWarning,
85 shouldHighlightDesktopUpdateError,
86 shouldShowDesktopUpdateButton,
87 shouldToastDesktopUpdateActionResult,84 shouldToastDesktopUpdateActionResult,
88} from "./desktopUpdate.logic";85} from "./desktopUpdate.logic";
89import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";86import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
...@@ -120,6 +117,7 @@ import {...@@ -120,6 +117,7 @@ import {
120 sortProjectsForSidebar,117 sortProjectsForSidebar,
121 sortThreadsForSidebar,118 sortThreadsForSidebar,
122} from "./Sidebar.logic";119} from "./Sidebar.logic";
120import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
123import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";121import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
124import { useSettings, useUpdateSettings } from "~/hooks/useSettings";122import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
125123
...@@ -1661,12 +1659,6 @@ export default function Sidebar() {...@@ -1661,12 +1659,6 @@ export default function Sidebar() {
1661 };1659 };
1662 }, []);1660 }, []);
16631661
1664 const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState);
1665
1666 const desktopUpdateTooltip = desktopUpdateState
1667 ? getDesktopUpdateButtonTooltip(desktopUpdateState)
1668 : "Update available";
1669
1670 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);1662 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
1671 const desktopUpdateButtonAction = desktopUpdateState1663 const desktopUpdateButtonAction = desktopUpdateState
1672 ? resolveDesktopUpdateButtonAction(desktopUpdateState)1664 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
...@@ -1677,17 +1669,6 @@ export default function Sidebar() {...@@ -1677,17 +1669,6 @@ export default function Sidebar() {
1677 desktopUpdateState && showArm64IntelBuildWarning1669 desktopUpdateState && showArm64IntelBuildWarning
1678 ? getArm64IntelBuildWarningDescription(desktopUpdateState)1670 ? getArm64IntelBuildWarningDescription(desktopUpdateState)
1679 : null;1671 : 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";
1691 const newThreadShortcutLabel =1672 const newThreadShortcutLabel =
1692 shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??1673 shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
1693 shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);1674 shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
...@@ -1728,6 +1709,10 @@ export default function Sidebar() {...@@ -1728,6 +1709,10 @@ export default function Sidebar() {
1728 }1709 }
17291710
1730 if (desktopUpdateButtonAction === "install") {1711 if (desktopUpdateButtonAction === "install") {
1712 const confirmed = window.confirm(
1713 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState),
1714 );
1715 if (!confirmed) return;
1731 void bridge1716 void bridge
1732 .installUpdate()1717 .installUpdate()
1733 .then((result) => {1718 .then((result) => {
...@@ -1795,30 +1780,9 @@ export default function Sidebar() {...@@ -1795,30 +1780,9 @@ export default function Sidebar() {
1795 return (1780 return (
1796 <>1781 <>
1797 {isElectron ? (1782 {isElectron ? (
1798 <>1783 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
1799 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">1784 {wordmark}
1800 {wordmark}1785 </SidebarHeader>
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 </>
1822 ) : (1786 ) : (
1823 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">1787 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
1824 {wordmark}1788 {wordmark}
...@@ -2006,6 +1970,7 @@ export default function Sidebar() {...@@ -2006,6 +1970,7 @@ export default function Sidebar() {
20061970
2007 <SidebarSeparator />1971 <SidebarSeparator />
2008 <SidebarFooter className="p-2">1972 <SidebarFooter className="p-2">
1973 <SidebarUpdatePill />
2009 <SidebarMenu>1974 <SidebarMenu>
2010 <SidebarMenuItem>1975 <SidebarMenuItem>
2011 <SidebarMenuButton1976 <SidebarMenuButton
apps/web/src/components/desktopUpdate.logic.test.ts+95-20
...@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";...@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
2import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
33
4import {4import {
5 canCheckForUpdate,
5 getArm64IntelBuildWarningDescription,6 getArm64IntelBuildWarningDescription,
6 getDesktopUpdateActionError,7 getDesktopUpdateActionError,
7 getDesktopUpdateButtonTooltip,8 getDesktopUpdateButtonTooltip,
9 getDesktopUpdateInstallConfirmationMessage,
8 isDesktopUpdateButtonDisabled,10 isDesktopUpdateButtonDisabled,
9 resolveDesktopUpdateButtonAction,11 resolveDesktopUpdateButtonAction,
10 shouldHighlightDesktopUpdateError,
11 shouldShowArm64IntelBuildWarning,12 shouldShowArm64IntelBuildWarning,
12 shouldShowDesktopUpdateButton,13 shouldShowDesktopUpdateButton,
13 shouldToastDesktopUpdateActionResult,14 shouldToastDesktopUpdateActionResult,
...@@ -69,6 +70,16 @@ describe("desktop update button state", () => {...@@ -69,6 +70,16 @@ describe("desktop update button state", () => {
69 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");70 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");
70 });71 });
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
72 it("hides the button for non-actionable check errors", () => {83 it("hides the button for non-actionable check errors", () => {
73 const state: DesktopUpdateState = {84 const state: DesktopUpdateState = {
74 ...baseState,85 ...baseState,
...@@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => {...@@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => {
169 ).toBe(false);180 ).toBe(false);
170 });181 });
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
191 it("shows an Apple Silicon warning for Intel builds under Rosetta", () => {183 it("shows an Apple Silicon warning for Intel builds under Rosetta", () => {
192 const state: DesktopUpdateState = {184 const state: DesktopUpdateState = {
193 ...baseState,185 ...baseState,
...@@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => {...@@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => {
213205
214 expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");206 expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
215 });207 });
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 });
216});291});
apps/web/src/components/desktopUpdate.logic.ts+21-7
...@@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none";...@@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none";
5export function resolveDesktopUpdateButtonAction(5export function resolveDesktopUpdateButtonAction(
6 state: DesktopUpdateState,6 state: DesktopUpdateState,
7): DesktopUpdateButtonAction {7): DesktopUpdateButtonAction {
8 if (state.downloadedVersion) {
9 return "install";
10 }
8 if (state.status === "available") {11 if (state.status === "available") {
9 return "download";12 return "download";
10 }13 }
11 if (state.status === "downloaded") {
12 return "install";
13 }
14 if (state.status === "error") {14 if (state.status === "error") {
15 if (state.errorContext === "install" && state.downloadedVersion) {
16 return "install";
17 }
18 if (state.errorContext === "download" && state.availableVersion) {15 if (state.errorContext === "download" && state.availableVersion) {
19 return "download";16 return "download";
20 }17 }
...@@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string...@@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
76 }73 }
77 return state.message ?? "Update failed";74 return state.message ?? "Update failed";
78 }75 }
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.`;
80}84}
8185
82export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null {86export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null {
...@@ -94,3 +98,13 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu...@@ -94,3 +98,13 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu
94 if (!state || state.status !== "error") return false;98 if (!state || state.status !== "error") return false;
95 return state.errorContext === "download" || state.errorContext === "install";99 return state.errorContext === "download" || state.errorContext === "install";
96}100}
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";...@@ -22,12 +22,24 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
22import { normalizeModelSlug } from "@t3tools/shared/model";22import { normalizeModelSlug } from "@t3tools/shared/model";
23import { Equal } from "effect";23import { Equal } from "effect";
24import { APP_VERSION } from "../../branding";24import { APP_VERSION } from "../../branding";
25import {
26 canCheckForUpdate,
27 getDesktopUpdateButtonTooltip,
28 getDesktopUpdateInstallConfirmationMessage,
29 isDesktopUpdateButtonDisabled,
30 resolveDesktopUpdateButtonAction,
31} from "../../components/desktopUpdate.logic";
25import { ProviderModelPicker } from "../chat/ProviderModelPicker";32import { ProviderModelPicker } from "../chat/ProviderModelPicker";
26import { TraitsPicker } from "../chat/TraitsPicker";33import { TraitsPicker } from "../chat/TraitsPicker";
27import { resolveAndPersistPreferredEditor } from "../../editorPreferences";34import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
35import { isElectron } from "../../env";
28import { useTheme } from "../../hooks/useTheme";36import { useTheme } from "../../hooks/useTheme";
29import { useSettings, useUpdateSettings } from "../../hooks/useSettings";37import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
30import { useThreadActions } from "../../hooks/useThreadActions";38import { useThreadActions } from "../../hooks/useThreadActions";
39import {
40 setDesktopUpdateStateQueryData,
41 useDesktopUpdateState,
42} from "../../lib/desktopUpdateReactQuery";
31import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery";43import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery";
32import {44import {
33 MAX_CUSTOM_MODEL_LENGTH,45 MAX_CUSTOM_MODEL_LENGTH,
...@@ -236,7 +248,7 @@ function SettingsRow({...@@ -236,7 +248,7 @@ function SettingsRow({
236 control,248 control,
237 children,249 children,
238}: {250}: {
239 title: string;251 title: ReactNode;
240 description: string;252 description: string;
241 status?: ReactNode;253 status?: ReactNode;
242 resetAction?: ReactNode;254 resetAction?: ReactNode;
...@@ -299,6 +311,133 @@ function SettingsPageContainer({ children }: { children: ReactNode }) {...@@ -299,6 +311,133 @@ function SettingsPageContainer({ children }: { children: ReactNode }) {
299 );311 );
300}312}
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
302export function useSettingsRestore(onRestored?: () => void) {441export function useSettingsRestore(onRestored?: () => void) {
303 const { theme, setTheme } = useTheme();442 const { theme, setTheme } = useTheme();
304 const settings = useSettings();443 const settings = useSettings();
...@@ -1258,12 +1397,17 @@ export function GeneralSettingsPanel() {...@@ -1258,12 +1397,17 @@ export function GeneralSettingsPanel() {
1258 </Button>1397 </Button>
1259 }1398 }
1260 />1399 />
1400 </SettingsSection>
12611401
1262 <SettingsRow1402 <SettingsSection title="About">
1263 title="Version"1403 {isElectron ? (
1264 description="Current application version."1404 <AboutVersionSection />
1265 control={<code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>}1405 ) : (
1266 />1406 <SettingsRow
1407 title={<AboutVersionTitle />}
1408 description="Current version of the application."
1409 />
1410 )}
1267 </SettingsSection>1411 </SettingsSection>
1268 </SettingsPageContainer>1412 </SettingsPageContainer>
1269 );1413 );
...@@ -1325,7 +1469,7 @@ export function ArchivedThreadsPanel() {...@@ -1325,7 +1469,7 @@ export function ArchivedThreadsPanel() {
1325 <SettingsPageContainer>1469 <SettingsPageContainer>
1326 {archivedGroups.length === 0 ? (1470 {archivedGroups.length === 0 ? (
1327 <SettingsSection title="Archived threads">1471 <SettingsSection title="Archived threads">
1328 <Empty className="min-h-[22rem]">1472 <Empty className="min-h-88">
1329 <EmptyMedia variant="icon">1473 <EmptyMedia variant="icon">
1330 <ArchiveIcon />1474 <ArchiveIcon />
1331 </EmptyMedia>1475 </EmptyMedia>
apps/web/src/components/settings/SettingsSidebarNav.tsx+1-1
...@@ -20,7 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{...@@ -20,7 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
20 icon: ComponentType<{ className?: string }>;20 icon: ComponentType<{ className?: string }>;
21}> = [21}> = [
22 { label: "General", to: "/settings/general", icon: Settings2Icon },22 { label: "General", to: "/settings/general", icon: Settings2Icon },
23 { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon },23 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
24];24];
2525
26export function SettingsSidebarNav({ pathname }: { pathname: string }) {26export 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 {...@@ -101,6 +101,11 @@ export interface DesktopUpdateActionResult {
101 state: DesktopUpdateState;101 state: DesktopUpdateState;
102}102}
103103
104export interface DesktopUpdateCheckResult {
105 checked: boolean;
106 state: DesktopUpdateState;
107}
108
104export interface DesktopBridge {109export interface DesktopBridge {
105 getWsUrl: () => string | null;110 getWsUrl: () => string | null;
106 pickFolder: () => Promise<string | null>;111 pickFolder: () => Promise<string | null>;
...@@ -113,6 +118,7 @@ export interface DesktopBridge {...@@ -113,6 +118,7 @@ export interface DesktopBridge {
113 openExternal: (url: string) => Promise<boolean>;118 openExternal: (url: string) => Promise<boolean>;
114 onMenuAction: (listener: (action: string) => void) => () => void;119 onMenuAction: (listener: (action: string) => void) => () => void;
115 getUpdateState: () => Promise<DesktopUpdateState>;120 getUpdateState: () => Promise<DesktopUpdateState>;
121 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;
116 downloadUpdate: () => Promise<DesktopUpdateActionResult>;122 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
117 installUpdate: () => Promise<DesktopUpdateActionResult>;123 installUpdate: () => Promise<DesktopUpdateActionResult>;
118 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;124 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;