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