From da3b690ef98fcf52432b0a7be0dc38e9d609c71a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 19:17:06 -0700 Subject: [PATCH 1/8] add update modal, auto-download, changelog modal --- apps/code/src/main/di/container.ts | 2 + .../platform-adapters/electron-updater.ts | 63 +++++- .../src/renderer/platform-adapters/updates.ts | 62 +++++- packages/core/src/updates/schemas.ts | 6 + packages/core/src/updates/updateStore.test.ts | 9 +- packages/core/src/updates/updateStore.ts | 60 +++++- packages/core/src/updates/updates.test.ts | 62 +++++- packages/core/src/updates/updates.ts | 114 ++++++++++- packages/host-router/src/router.ts | 2 + .../src/routers/github-releases.router.ts | 14 ++ .../host-router/src/routers/updates.router.ts | 13 ++ packages/platform/src/updater.ts | 21 +- .../settings/sections/GeneralSettings.tsx | 48 ++++- .../ui/src/features/settings/settingsStore.ts | 12 ++ .../sidebar/components/ProjectSwitcher.tsx | 12 ++ .../sidebar/components/UpdateBanner.tsx | 181 +++++++++++------- .../features/updates/UpdateAvailableModal.tsx | 139 ++++++++++++++ .../ui/src/features/updates/WhatsNewModal.tsx | 108 +++++++++++ .../src/features/updates/updateModalStore.ts | 13 ++ .../ui/src/features/updates/updateStore.ts | 8 + .../ui/src/features/updates/whatsNewStore.ts | 13 ++ packages/ui/src/router/routes/__root.tsx | 8 + .../github-releases/github-releases.module.ts | 7 + .../github-releases/github-releases.test.ts | 84 ++++++++ .../github-releases/github-releases.ts | 45 +++++ .../services/github-releases/identifiers.ts | 3 + .../src/services/github-releases/schemas.ts | 29 +++ 27 files changed, 1043 insertions(+), 95 deletions(-) create mode 100644 packages/host-router/src/routers/github-releases.router.ts create mode 100644 packages/ui/src/features/updates/UpdateAvailableModal.tsx create mode 100644 packages/ui/src/features/updates/WhatsNewModal.tsx create mode 100644 packages/ui/src/features/updates/updateModalStore.ts create mode 100644 packages/ui/src/features/updates/whatsNewStore.ts create mode 100644 packages/workspace-server/src/services/github-releases/github-releases.module.ts create mode 100644 packages/workspace-server/src/services/github-releases/github-releases.test.ts create mode 100644 packages/workspace-server/src/services/github-releases/github-releases.ts create mode 100644 packages/workspace-server/src/services/github-releases/identifiers.ts create mode 100644 packages/workspace-server/src/services/github-releases/schemas.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 54ab5b18ee..9f1d431eca 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -163,6 +163,7 @@ import type { ExternalAppsPreferences } from "@posthog/workspace-server/services import { foldersModule } from "@posthog/workspace-server/services/folders/folders.module"; import { GitService } from "@posthog/workspace-server/services/git/service"; import { TaskPrStatusService } from "@posthog/workspace-server/services/git/task-pr-status"; +import { githubReleasesModule } from "@posthog/workspace-server/services/github-releases/github-releases.module"; import { HANDOFF_GIT_GATEWAY, HANDOFF_LOG_GATEWAY, @@ -584,6 +585,7 @@ container.load(posthogPluginModule); container.bind(MAIN_POSTHOG_PLUGIN_SERVICE).toService(POSTHOG_PLUGIN_SERVICE); container.load(skillsModule); container.load(skillsMarketplaceModule); +container.load(githubReleasesModule); container.load(onboardingImportModule); container.load(additionalDirectoriesModule); container.bind(MAIN_SLEEP_SERVICE).to(SleepService); diff --git a/apps/code/src/main/platform-adapters/electron-updater.ts b/apps/code/src/main/platform-adapters/electron-updater.ts index e4c9cbf9be..6f22e61ce6 100644 --- a/apps/code/src/main/platform-adapters/electron-updater.ts +++ b/apps/code/src/main/platform-adapters/electron-updater.ts @@ -1,14 +1,39 @@ -import type { IUpdater } from "@posthog/platform/updater"; +import type { + IUpdater, + UpdateAvailableInfo, + UpdateDownloadProgress, +} from "@posthog/platform/updater"; import { app } from "electron"; import log from "electron-log/main"; -import { autoUpdater, type UpdateInfo } from "electron-updater"; +import { + autoUpdater, + type ProgressInfo, + type UpdateInfo, +} from "electron-updater"; import { injectable } from "inversify"; +function normalizeReleaseNotes( + notes: UpdateInfo["releaseNotes"], +): string | null { + if (!notes) return null; + if (typeof notes === "string") return notes; + const joined = notes + .map((n) => n.note ?? "") + .filter((n) => n.length > 0) + .join("\n\n"); + return joined.length > 0 ? joined : null; +} + @injectable() export class ElectronUpdater implements IUpdater { constructor() { autoUpdater.logger = log; autoUpdater.disableDifferentialDownload = true; + // Default to manual download; the "Download updates automatically" setting + // flips this via setAutoDownload(). A downloaded update always installs on the + // next quit, with an in-app Restart button for immediate install. + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; } public isSupported(): boolean { @@ -23,21 +48,51 @@ export class ElectronUpdater implements IUpdater { void autoUpdater.checkForUpdates(); } + public download(): void { + void autoUpdater.downloadUpdate(); + } + public quitAndInstall(): void { autoUpdater.quitAndInstall(false, true); } + public setAutoDownload(enabled: boolean): void { + autoUpdater.autoDownload = enabled; + } + public onCheckStart(handler: () => void): () => void { autoUpdater.on("checking-for-update", handler); return () => autoUpdater.off("checking-for-update", handler); } - public onUpdateAvailable(handler: () => void): () => void { - const l = (_info: UpdateInfo) => handler(); + public onUpdateAvailable( + handler: (info: UpdateAvailableInfo) => void, + ): () => void { + const l = (info: UpdateInfo) => + handler({ + version: info.version, + releaseNotes: normalizeReleaseNotes(info.releaseNotes), + releaseDate: info.releaseDate, + releaseName: info.releaseName, + }); autoUpdater.on("update-available", l); return () => autoUpdater.off("update-available", l); } + public onDownloadProgress( + handler: (progress: UpdateDownloadProgress) => void, + ): () => void { + const l = (info: ProgressInfo) => + handler({ + percent: info.percent, + bytesPerSecond: info.bytesPerSecond, + transferred: info.transferred, + total: info.total, + }); + autoUpdater.on("download-progress", l); + return () => autoUpdater.off("download-progress", l); + } + public onUpdateDownloaded(handler: (version: string) => void): () => void { const l = (info: UpdateInfo) => handler(info.version); autoUpdater.on("update-downloaded", l); diff --git a/apps/code/src/renderer/platform-adapters/updates.ts b/apps/code/src/renderer/platform-adapters/updates.ts index 7682e8ca44..360ea74fad 100644 --- a/apps/code/src/renderer/platform-adapters/updates.ts +++ b/apps/code/src/renderer/platform-adapters/updates.ts @@ -6,12 +6,15 @@ import { updateStore, } from "@posthog/core/updates/updateStore"; import { resolveService } from "@posthog/di/container"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { UPDATES_CLIENT, type UpdatesClient, } from "@posthog/ui/features/updates/updatesClient"; +import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; import { toast } from "@posthog/ui/primitives/toast"; import { logger } from "@posthog/ui/shell/logger"; +import { hostTrpcClient } from "@renderer/trpc/client"; const log = logger.scope("updates-host"); @@ -44,11 +47,8 @@ void client .getStatus() .then((status) => { const update = deriveUpdateUiStatus(status, store().status); - if (update?.status) { - store().setStatus(update.status); - } - if (update && "version" in update) { - store().setVersion(update.version ?? null); + if (update) { + store().applyStatusUpdate(update); } }) .catch((error: unknown) => { @@ -58,11 +58,8 @@ void client client.onStatus({ onData: (status) => { const update = deriveUpdateUiStatus(status, store().status); - if (update?.status) { - store().setStatus(update.status); - } - if (update && "version" in update) { - store().setVersion(update.version ?? null); + if (update) { + store().applyStatusUpdate(update); } const outcome = resolveMenuCheckFromStatus( @@ -119,3 +116,48 @@ client.onCheckFromMenu({ log.error("Update menu check subscription error", { error }); }, }); + +// Bridge the "download updates automatically" preference to the core updater. +let lastSyncedAutoDownload: boolean | null = null; +function syncAutoDownload(enabled: boolean): void { + if (enabled === lastSyncedAutoDownload) return; + lastSyncedAutoDownload = enabled; + void hostTrpcClient.updates.setAutoDownload + .mutate({ enabled }) + .catch((error: unknown) => + log.error("Failed to sync auto-download preference", { error }), + ); +} + +// Auto-show "What's New" once on the first launch after the version changes. +function maybeShowWhatsNew(): void { + void hostTrpcClient.os.getAppVersion + .query() + .then((currentVersion) => { + const settings = useSettingsStore.getState(); + const lastSeen = settings.lastSeenChangelogVersion; + if (lastSeen && lastSeen !== currentVersion) { + useWhatsNewStore.getState().open(); + } + if (lastSeen !== currentVersion) { + settings.setLastSeenChangelogVersion(currentVersion); + } + }) + .catch((error: unknown) => + log.error("Failed to evaluate What's New", { error }), + ); +} + +function onSettingsReady(): void { + syncAutoDownload(useSettingsStore.getState().downloadUpdatesAutomatically); + useSettingsStore.subscribe((state) => + syncAutoDownload(state.downloadUpdatesAutomatically), + ); + maybeShowWhatsNew(); +} + +if (useSettingsStore.persist.hasHydrated()) { + onSettingsReady(); +} else { + useSettingsStore.persist.onFinishHydration(onSettingsReady); +} diff --git a/packages/core/src/updates/schemas.ts b/packages/core/src/updates/schemas.ts index b4dd973948..4e36231afb 100644 --- a/packages/core/src/updates/schemas.ts +++ b/packages/core/src/updates/schemas.ts @@ -16,10 +16,16 @@ export const checkForUpdatesOutput = z.object({ export const updatesStatusOutput = z.object({ checking: z.boolean(), downloading: z.boolean().optional(), + available: z.boolean().optional(), upToDate: z.boolean().optional(), updateReady: z.boolean().optional(), installing: z.boolean().optional(), version: z.string().optional(), + availableVersion: z.string().optional(), + releaseNotes: z.string().nullable().optional(), + releaseDate: z.string().optional(), + downloadPercent: z.number().optional(), + bytesPerSecond: z.number().optional(), error: z.string().optional(), }); diff --git a/packages/core/src/updates/updateStore.test.ts b/packages/core/src/updates/updateStore.test.ts index db1489433d..5ec7267f76 100644 --- a/packages/core/src/updates/updateStore.test.ts +++ b/packages/core/src/updates/updateStore.test.ts @@ -27,7 +27,14 @@ describe("deriveUpdateUiStatus", () => { it("maps checking + downloading to downloading", () => { expect( deriveUpdateUiStatus({ checking: true, downloading: true }, "idle"), - ).toEqual({ status: "downloading" }); + ).toEqual({ + status: "downloading", + availableVersion: null, + releaseNotes: null, + releaseDate: null, + downloadPercent: null, + bytesPerSecond: null, + }); }); it("maps checking to checking", () => { diff --git a/packages/core/src/updates/updateStore.ts b/packages/core/src/updates/updateStore.ts index fa3139141e..46dfa3418e 100644 --- a/packages/core/src/updates/updateStore.ts +++ b/packages/core/src/updates/updateStore.ts @@ -3,6 +3,7 @@ import { createStore } from "zustand/vanilla"; export type UpdateUiStatus = | "idle" + | "available" | "checking" | "downloading" | "ready" @@ -11,6 +12,11 @@ export type UpdateUiStatus = interface UpdateState { status: UpdateUiStatus; version: string | null; + availableVersion: string | null; + releaseNotes: string | null; + releaseDate: string | null; + downloadPercent: number | null; + bytesPerSecond: number | null; isEnabled: boolean; menuCheckPending: boolean; @@ -19,11 +25,17 @@ interface UpdateState { setEnabled: (isEnabled: boolean) => void; setMenuCheckPending: (menuCheckPending: boolean) => void; setReady: (version: string | null) => void; + applyStatusUpdate: (update: UpdateStatusUpdate) => void; } export const updateStore = createStore((set) => ({ status: "idle", version: null, + availableVersion: null, + releaseNotes: null, + releaseDate: null, + downloadPercent: null, + bytesPerSecond: null, isEnabled: false, menuCheckPending: false, @@ -32,6 +44,31 @@ export const updateStore = createStore((set) => ({ setEnabled: (isEnabled) => set({ isEnabled }), setMenuCheckPending: (menuCheckPending) => set({ menuCheckPending }), setReady: (version) => set({ status: "ready", version }), + applyStatusUpdate: (update) => + set((state) => ({ + status: update.status ?? state.status, + version: update.version !== undefined ? update.version : state.version, + availableVersion: + update.availableVersion !== undefined + ? update.availableVersion + : state.availableVersion, + releaseNotes: + update.releaseNotes !== undefined + ? update.releaseNotes + : state.releaseNotes, + releaseDate: + update.releaseDate !== undefined + ? update.releaseDate + : state.releaseDate, + downloadPercent: + update.downloadPercent !== undefined + ? update.downloadPercent + : state.downloadPercent, + bytesPerSecond: + update.bytesPerSecond !== undefined + ? update.bytesPerSecond + : state.bytesPerSecond, + })), })); export const getUpdateUiStatus = () => updateStore.getState().status; @@ -42,6 +79,11 @@ export const getMenuCheckPending = () => export interface UpdateStatusUpdate { status?: UpdateUiStatus; version?: string | null; + availableVersion?: string | null; + releaseNotes?: string | null; + releaseDate?: string | null; + downloadPercent?: number | null; + bytesPerSecond?: number | null; } export function deriveUpdateUiStatus( @@ -57,7 +99,23 @@ export function deriveUpdateUiStatus( } if (payload.checking && payload.downloading) { - return { status: "downloading" }; + return { + status: "downloading", + availableVersion: payload.availableVersion ?? null, + releaseNotes: payload.releaseNotes ?? null, + releaseDate: payload.releaseDate ?? null, + downloadPercent: payload.downloadPercent ?? null, + bytesPerSecond: payload.bytesPerSecond ?? null, + }; + } + + if (payload.available) { + return { + status: "available", + availableVersion: payload.availableVersion ?? null, + releaseNotes: payload.releaseNotes ?? null, + releaseDate: payload.releaseDate ?? null, + }; } if (payload.checking) { diff --git a/packages/core/src/updates/updates.test.ts b/packages/core/src/updates/updates.test.ts index 3ba6e9f8d3..e84cd0e09b 100644 --- a/packages/core/src/updates/updates.test.ts +++ b/packages/core/src/updates/updates.test.ts @@ -1,3 +1,7 @@ +import type { + UpdateAvailableInfo, + UpdateDownloadProgress, +} from "@posthog/platform/updater"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { UpdatesEvent } from "./schemas"; @@ -13,7 +17,8 @@ const { } = vi.hoisted(() => { const updaterHandlers: { checkStart: (() => void) | null; - updateAvailable: (() => void) | null; + updateAvailable: ((info: UpdateAvailableInfo) => void) | null; + downloadProgress: ((progress: UpdateDownloadProgress) => void) | null; noUpdate: (() => void) | null; updateDownloaded: ((version: string) => void) | null; error: ((error: Error) => void) | null; @@ -21,6 +26,7 @@ const { } = { checkStart: null, updateAvailable: null, + downloadProgress: null, noUpdate: null, updateDownloaded: null, error: null, @@ -37,10 +43,18 @@ const { updaterHandlers.checkStart = h; return () => {}; }), - onUpdateAvailable: vi.fn((h: () => void) => { + onUpdateAvailable: vi.fn((h: (info: UpdateAvailableInfo) => void) => { updaterHandlers.updateAvailable = h; return () => {}; }), + onDownloadProgress: vi.fn( + (h: (progress: UpdateDownloadProgress) => void) => { + updaterHandlers.downloadProgress = h; + return () => {}; + }, + ), + download: vi.fn(), + setAutoDownload: vi.fn(), onNoUpdate: vi.fn((h: () => void) => { updaterHandlers.noUpdate = h; return () => {}; @@ -656,14 +670,54 @@ describe("UpdatesService", () => { }); }); - it("returns downloading status while an update is downloading", async () => { + it("returns available status when an update is found", async () => { await initializeService(service); - updaterHandlers.updateAvailable?.(); + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: "Notes", + }); expect(service.getStatus()).toEqual({ + checking: false, + available: true, + availableVersion: "v2.0.0", + releaseNotes: "Notes", + }); + }); + + it("auto-downloads and returns downloading status when enabled", async () => { + await initializeService(service); + service.setAutoDownloadEnabled(true); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: null, + }); + + expect(mockUpdater.download).toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ checking: true, downloading: true, + availableVersion: "v2.0.0", + }); + }); + + it("downloads on requestDownload and reaches ready", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: null, + }); + service.requestDownload(); + expect(mockUpdater.download).toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ downloading: true }); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + expect(service.getStatus()).toMatchObject({ + updateReady: true, + version: "v2.0.0", }); }); }); diff --git a/packages/core/src/updates/updates.ts b/packages/core/src/updates/updates.ts index 04f02128c6..74ceff71e7 100644 --- a/packages/core/src/updates/updates.ts +++ b/packages/core/src/updates/updates.ts @@ -8,7 +8,12 @@ import { type IMainWindow, MAIN_WINDOW_SERVICE, } from "@posthog/platform/main-window"; -import { type IUpdater, UPDATER_SERVICE } from "@posthog/platform/updater"; +import { + type IUpdater, + UPDATER_SERVICE, + type UpdateAvailableInfo, + type UpdateDownloadProgress, +} from "@posthog/platform/updater"; import { type SagaLogger, TypedEventEmitter, @@ -28,6 +33,7 @@ type CheckSource = "user" | "periodic"; type UpdateState = | "idle" | "checking" + | "available" | "downloading" | "ready" | "installing" @@ -82,6 +88,10 @@ export class UpdatesService extends TypedEventEmitter { private lastError: string | null = null; private initialized = false; private unsubscribes: Array<() => void> = []; + private availableInfo: UpdateAvailableInfo | null = null; + private downloadProgress: UpdateDownloadProgress | null = null; + private autoDownloadEnabled = false; + private lastProgressEmit = 0; get hasUpdateReady(): boolean { return this.isUpdateStaged(); @@ -112,13 +122,43 @@ export class UpdatesService extends TypedEventEmitter { this.emit(UpdatesEvent.CheckFromMenu, true); } + setAutoDownloadEnabled(enabled: boolean): void { + this.autoDownloadEnabled = enabled; + if (this.isEnabled) { + this.updater.setAutoDownload(enabled); + } + this.log.info("Auto-download preference updated", { enabled }); + } + + requestDownload(): void { + if (this.state !== "available") { + this.log.warn("requestDownload called but no update is available", { + state: this.state, + }); + return; + } + this.transitionTo("downloading", { + reason: "user requested download", + incomingVersion: this.availableInfo?.version ?? null, + }); + this.log.info("Downloading update...", { + version: this.availableInfo?.version, + }); + this.updater.download(); + this.emitStatus(this.downloadingStatusPayload()); + } + getStatus(): UpdatesStatusPayload { if (this.state === "checking") { return { checking: true }; } + if (this.state === "available") { + return this.availableStatusPayload(); + } + if (this.state === "downloading") { - return { checking: true, downloading: true }; + return this.downloadingStatusPayload(); } if (this.isUpdateStaged()) { @@ -238,7 +278,12 @@ export class UpdatesService extends TypedEventEmitter { this.unsubscribes.push( this.updater.onError((error) => this.handleError(error)), this.updater.onCheckStart(() => this.log.info("Checking for updates...")), - this.updater.onUpdateAvailable(() => this.handleUpdateAvailable()), + this.updater.onUpdateAvailable((info) => + this.handleUpdateAvailable(info), + ), + this.updater.onDownloadProgress((progress) => + this.handleDownloadProgress(progress), + ), this.updater.onNoUpdate(() => this.handleNoUpdate()), this.updater.onUpdateDownloaded((releaseName) => this.handleUpdateDownloaded(releaseName), @@ -262,6 +307,28 @@ export class UpdatesService extends TypedEventEmitter { }; } + private availableStatusPayload(): UpdatesStatusPayload { + return { + checking: false, + available: true, + availableVersion: this.availableInfo?.version, + releaseNotes: this.availableInfo?.releaseNotes ?? undefined, + releaseDate: this.availableInfo?.releaseDate, + }; + } + + private downloadingStatusPayload(): UpdatesStatusPayload { + return { + checking: true, + downloading: true, + availableVersion: this.availableInfo?.version, + releaseNotes: this.availableInfo?.releaseNotes ?? undefined, + releaseDate: this.availableInfo?.releaseDate, + downloadPercent: this.downloadProgress?.percent, + bytesPerSecond: this.downloadProgress?.bytesPerSecond, + }; + } + private handleError(error: Error): void { this.clearCheckTimeout(); this.log.error("Auto update error", { @@ -289,7 +356,7 @@ export class UpdatesService extends TypedEventEmitter { } } - private handleUpdateAvailable(): void { + private handleUpdateAvailable(info: UpdateAvailableInfo): void { if (this.isUpdateStaged()) { this.log.info( "Ignoring update-available because an update is already staged", @@ -301,9 +368,42 @@ export class UpdatesService extends TypedEventEmitter { } this.clearCheckTimeout(); - this.transitionTo("downloading", { reason: "update available" }); - this.log.info("Update available, downloading..."); - this.emitStatus({ checking: true, downloading: true }); + this.availableInfo = info; + this.downloadProgress = null; + + if (this.autoDownloadEnabled) { + this.transitionTo("downloading", { + reason: "update available (auto-download)", + incomingVersion: info.version, + }); + this.log.info("Update available, auto-downloading...", { + version: info.version, + }); + this.updater.download(); + this.emitStatus(this.downloadingStatusPayload()); + return; + } + + this.transitionTo("available", { + reason: "update available", + incomingVersion: info.version, + }); + this.log.info("Update available, awaiting user download", { + version: info.version, + }); + this.emitStatus(this.availableStatusPayload()); + } + + private handleDownloadProgress(progress: UpdateDownloadProgress): void { + if (this.state !== "downloading") { + return; + } + this.downloadProgress = progress; + const now = Date.now(); + if (now - this.lastProgressEmit >= 400 || progress.percent >= 100) { + this.lastProgressEmit = now; + this.emitStatus(this.downloadingStatusPayload()); + } } private handleNoUpdate(): void { diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index 6370158e1a..e3b96d20a3 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -23,6 +23,7 @@ import { freeformGenRouter } from "./routers/freeform-gen.router"; import { fsRouter } from "./routers/fs.router"; import { gitRouter } from "./routers/git.router"; import { githubIntegrationRouter } from "./routers/github-integration.router"; +import { githubReleasesRouter } from "./routers/github-releases.router"; import { handoffRouter } from "./routers/handoff.router"; import { linearIntegrationRouter } from "./routers/linear-integration.router"; import { llmGatewayRouter } from "./routers/llm-gateway.router"; @@ -72,6 +73,7 @@ export const hostRouter = router({ git: gitRouter, handoff: handoffRouter, githubIntegration: githubIntegrationRouter, + githubReleases: githubReleasesRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, logs: logsRouter, diff --git a/packages/host-router/src/routers/github-releases.router.ts b/packages/host-router/src/routers/github-releases.router.ts new file mode 100644 index 0000000000..0e183f9eba --- /dev/null +++ b/packages/host-router/src/routers/github-releases.router.ts @@ -0,0 +1,14 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { GitHubReleasesService } from "@posthog/workspace-server/services/github-releases/github-releases"; +import { GITHUB_RELEASES_SERVICE } from "@posthog/workspace-server/services/github-releases/identifiers"; +import { listReleasesOutput } from "@posthog/workspace-server/services/github-releases/schemas"; + +export const githubReleasesRouter = router({ + list: publicProcedure + .output(listReleasesOutput) + .query(({ ctx }) => + ctx.container + .get(GITHUB_RELEASES_SERVICE) + .listReleases(), + ), +}); diff --git a/packages/host-router/src/routers/updates.router.ts b/packages/host-router/src/routers/updates.router.ts index b861c100bf..cb17491c25 100644 --- a/packages/host-router/src/routers/updates.router.ts +++ b/packages/host-router/src/routers/updates.router.ts @@ -9,6 +9,7 @@ import { } from "@posthog/core/updates/schemas"; import type { UpdatesService } from "@posthog/core/updates/updates"; import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; function subscribe(event: K) { return publicProcedure.subscription(async function* ({ ctx, signal }) { @@ -41,6 +42,18 @@ export const updatesRouter = router({ return service.installUpdate(); }), + download: publicProcedure.mutation(({ ctx }) => { + ctx.container.get(UPDATES_SERVICE).requestDownload(); + }), + + setAutoDownload: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ ctx, input }) => { + ctx.container + .get(UPDATES_SERVICE) + .setAutoDownloadEnabled(input.enabled); + }), + onReady: subscribe(UpdatesEvent.Ready), onStatus: subscribe(UpdatesEvent.Status), onCheckFromMenu: subscribe(UpdatesEvent.CheckFromMenu), diff --git a/packages/platform/src/updater.ts b/packages/platform/src/updater.ts index 7c314c3680..3e8eabbb61 100644 --- a/packages/platform/src/updater.ts +++ b/packages/platform/src/updater.ts @@ -1,9 +1,28 @@ +export interface UpdateAvailableInfo { + version: string; + releaseNotes: string | null; + releaseDate?: string; + releaseName?: string | null; +} + +export interface UpdateDownloadProgress { + percent: number; + bytesPerSecond: number; + transferred: number; + total: number; +} + export interface IUpdater { isSupported(): boolean; check(): void; + download(): void; quitAndInstall(): void; + setAutoDownload(enabled: boolean): void; onCheckStart(handler: () => void): () => void; - onUpdateAvailable(handler: () => void): () => void; + onUpdateAvailable(handler: (info: UpdateAvailableInfo) => void): () => void; + onDownloadProgress( + handler: (progress: UpdateDownloadProgress) => void, + ): () => void; onUpdateDownloaded(handler: (version: string) => void): () => void; onNoUpdate(handler: () => void): () => void; onError(handler: (error: Error) => void): () => void; diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 9702e59a72..83c4c7aa3e 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -46,14 +46,24 @@ export function GeneralSettings() { const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); // Power state - const { preventSleepWhileRunning, setPreventSleepWhileRunning } = - useSettingsStore(); + const { + preventSleepWhileRunning, + setPreventSleepWhileRunning, + downloadUpdatesAutomatically, + setDownloadUpdatesAutomatically, + } = useSettingsStore(); const { data: serverPreventSleep } = useQuery( hostTRPC.sleep.getEnabled.queryOptions(), ); const preventSleepMutation = useMutation( hostTRPC.sleep.setEnabled.mutationOptions(), ); + const { data: updatesEnabled } = useQuery( + hostTRPC.updates.isEnabled.queryOptions(), + ); + const autoDownloadMutation = useMutation( + hostTRPC.updates.setAutoDownload.mutationOptions(), + ); useEffect(() => { if (serverPreventSleep !== undefined) { @@ -74,6 +84,19 @@ export function GeneralSettings() { [setPreventSleepWhileRunning, preventSleepMutation], ); + const handleAutoDownloadChange = useCallback( + (checked: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "download_updates_automatically", + new_value: checked, + old_value: !checked, + }); + setDownloadUpdatesAutomatically(checked); + autoDownloadMutation.mutate({ enabled: checked }); + }, + [setDownloadUpdatesAutomatically, autoDownloadMutation], + ); + // Chat state const { desktopNotifications, @@ -591,6 +614,27 @@ export function GeneralSettings() { /> + {updatesEnabled?.enabled ? ( + <> + {/* Updates */} + + Updates + + + + + + + ) : null} + {/* Fun */} Fun diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index cfdf5c8757..2739da13fa 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -141,8 +141,12 @@ interface SettingsStore { // Experimental / misc hedgehogMode: boolean; mcpAppsDisabledServers: string[]; + downloadUpdatesAutomatically: boolean; + lastSeenChangelogVersion: string | null; setHedgehogMode: (enabled: boolean) => void; setMcpAppsDisabledServers: (servers: string[]) => void; + setDownloadUpdatesAutomatically: (enabled: boolean) => void; + setLastSeenChangelogVersion: (version: string | null) => void; // Onboarding hints hints: Record; @@ -263,7 +267,13 @@ export const useSettingsStore = create()( // Experimental / misc hedgehogMode: false, mcpAppsDisabledServers: [], + downloadUpdatesAutomatically: false, + lastSeenChangelogVersion: null, setHedgehogMode: (enabled) => set({ hedgehogMode: enabled }), + setDownloadUpdatesAutomatically: (enabled) => + set({ downloadUpdatesAutomatically: enabled }), + setLastSeenChangelogVersion: (version) => + set({ lastSeenChangelogVersion: version }), setMcpAppsDisabledServers: (servers) => set({ mcpAppsDisabledServers: servers }), @@ -349,6 +359,8 @@ export const useSettingsStore = create()( // Experimental / misc hedgehogMode: state.hedgehogMode, mcpAppsDisabledServers: state.mcpAppsDisabledServers, + downloadUpdatesAutomatically: state.downloadUpdatesAutomatically, + lastSeenChangelogVersion: state.lastSeenChangelogVersion, // Onboarding hints hints: state.hints, diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 3439ca6423..f80997dded 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -6,6 +6,7 @@ import { DiscordLogo, FolderSimple, Gear, + Gift, Info, Keyboard, Plus, @@ -50,6 +51,7 @@ import { import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { isMac } from "@posthog/ui/utils/platform"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -163,6 +165,11 @@ export function ProjectSwitcher({ setPopoverOpen(false); }; + const handleViewChangelog = () => { + useWhatsNewStore.getState().open(); + setPopoverOpen(false); + }; + const handleLogout = () => { setPopoverOpen(false); logoutMutation.mutate(); @@ -333,6 +340,11 @@ export function ProjectSwitcher({ + + + View changelog + + Settings diff --git a/packages/ui/src/features/sidebar/components/UpdateBanner.tsx b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx index b02cbe83c6..b09879396f 100644 --- a/packages/ui/src/features/sidebar/components/UpdateBanner.tsx +++ b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx @@ -1,4 +1,5 @@ import { ArrowsClockwise, Gift, Spinner } from "@phosphor-icons/react"; +import { useUpdateModalStore } from "@posthog/ui/features/updates/updateModalStore"; import { useInstallUpdate, useUpdateView, @@ -11,12 +12,20 @@ interface UpdateBannerProps { } export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { - const { status, version, isEnabled } = useUpdateView(); + const { status, version, availableVersion, downloadPercent } = + useUpdateView(); + const { isEnabled } = useUpdateView(); const installUpdate = useInstallUpdate(); + const openModal = useUpdateModalStore((state) => state.open); const isVisible = isEnabled && - (status === "downloading" || status === "ready" || status === "installing"); + (status === "available" || + status === "downloading" || + status === "ready" || + status === "installing"); + + const percent = Math.round(downloadPercent ?? 0); if (variant === "compact") { return ( @@ -28,11 +37,26 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { exit={{ opacity: 0, x: -8 }} transition={{ duration: 0.2, ease: "easeOut" }} > + {status === "available" && ( + + )} + {status === "downloading" && ( -
+
+ Downloading update... {percent}% + )} {status === "ready" && ( @@ -43,8 +67,7 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { > - {version ? `${version} available` : "Update available"} — - Restart + {version ? `${version} ready` : "Update ready"} — Restart )} @@ -72,62 +95,97 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { className="shrink-0 overflow-hidden" > + {status === "available" && ( + + + + )} + {status === "downloading" && ( - - - Downloading update... - + + + )} {status === "ready" && ( - - -
- - - -
- - {version ? `${version} ready` : "Update ready"} - - - Restart to apply - -
- -
-
-
+ +
+ + + + + +
+
)} {status === "installing" && ( - - - Restarting... - + +
+ + Restarting... +
+
)}
@@ -136,22 +194,15 @@ export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { ); } -function BannerContent({ - children, - ...props -}: { children: React.ReactNode } & React.ComponentProps) { +function BannerCard({ children }: { children: React.ReactNode }) { return ( -
- {children} -
+ {children}
); } diff --git a/packages/ui/src/features/updates/UpdateAvailableModal.tsx b/packages/ui/src/features/updates/UpdateAvailableModal.tsx new file mode 100644 index 0000000000..fcb6201935 --- /dev/null +++ b/packages/ui/src/features/updates/UpdateAvailableModal.tsx @@ -0,0 +1,139 @@ +import { ArrowDown, ArrowsClockwise } from "@phosphor-icons/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { useUpdateModalStore } from "@posthog/ui/features/updates/updateModalStore"; +import { + useInstallUpdate, + useUpdateView, +} from "@posthog/ui/features/updates/updateStore"; +import { + Button, + Code, + Dialog, + Flex, + Progress, + ScrollArea, + Text, +} from "@radix-ui/themes"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +function formatSpeed(bytesPerSecond: number | null): string { + if (!bytesPerSecond || bytesPerSecond <= 0) return ""; + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; +} + +export function UpdateAvailableModal() { + const isOpen = useUpdateModalStore((state) => state.isOpen); + const close = useUpdateModalStore((state) => state.close); + const { + status, + version, + availableVersion, + releaseNotes, + downloadPercent, + bytesPerSecond, + } = useUpdateView(); + const installUpdate = useInstallUpdate(); + const hostTRPC = useHostTRPC(); + const { data: currentVersion } = useQuery( + hostTRPC.os.getAppVersion.queryOptions(), + ); + const downloadMutation = useMutation( + hostTRPC.updates.download.mutationOptions(), + ); + + const targetVersion = version ?? availableVersion; + const percent = Math.round(downloadPercent ?? 0); + const isDownloading = status === "downloading"; + const isReady = status === "ready" || status === "installing"; + + return ( + { + if (!open) close(); + }} + > + + + + Update Available + + + {targetVersion + ? `PostHog Code ${targetVersion} is ready` + : "A new version is ready"} + + + + + {currentVersion ? ( + + + You're currently on version + + {currentVersion} + + ) : null} + + {releaseNotes ? ( + + + Release notes + + +
+ +
+
+
+ ) : null} + + {isDownloading ? ( + + + + Downloading... {percent}% + + + {formatSpeed(bytesPerSecond)} + + + + + ) : null} + + + + {isReady ? ( + + ) : isDownloading ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx new file mode 100644 index 0000000000..636d6ca247 --- /dev/null +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -0,0 +1,108 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; +import { + Badge, + Dialog, + Flex, + ScrollArea, + Spinner, + Text, +} from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; + +function formatDate(date: string | null): string { + if (!date) return ""; + const parsed = new Date(date); + if (Number.isNaN(parsed.getTime())) return ""; + return parsed.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export function WhatsNewModal() { + const isOpen = useWhatsNewStore((state) => state.isOpen); + const close = useWhatsNewStore((state) => state.close); + const hostTRPC = useHostTRPC(); + const { data, isLoading, isError } = useQuery({ + ...hostTRPC.githubReleases.list.queryOptions(), + enabled: isOpen, + }); + const { data: currentVersion } = useQuery( + hostTRPC.os.getAppVersion.queryOptions(), + ); + + const releases = data?.releases ?? []; + + return ( + { + if (!open) close(); + }} + > + + + What's New + + + Release history and recent improvements + + + + + {isLoading ? ( + + + + ) : isError ? ( + + Could not load releases. Please try again later. + + ) : releases.length === 0 ? ( + + No releases found. + + ) : ( + + + {releases.map((release, index) => ( + + + + + {release.name} + + {index === 0 ? Latest : null} + {currentVersion === release.version ? ( + + Current + + ) : null} + + + {formatDate(release.date)} + + + {release.notes ? ( + + ) : ( + + No release notes. + + )} + + ))} + + + )} + + + ); +} diff --git a/packages/ui/src/features/updates/updateModalStore.ts b/packages/ui/src/features/updates/updateModalStore.ts new file mode 100644 index 0000000000..f1ca6af5fa --- /dev/null +++ b/packages/ui/src/features/updates/updateModalStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +interface UpdateModalStore { + isOpen: boolean; + open: () => void; + close: () => void; +} + +export const useUpdateModalStore = create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), +})); diff --git a/packages/ui/src/features/updates/updateStore.ts b/packages/ui/src/features/updates/updateStore.ts index fbb67ea242..73cd29489e 100644 --- a/packages/ui/src/features/updates/updateStore.ts +++ b/packages/ui/src/features/updates/updateStore.ts @@ -16,6 +16,10 @@ const log = logger.scope("update-store"); interface UpdateView { status: UpdateUiStatus; version: string | null; + availableVersion: string | null; + releaseNotes: string | null; + downloadPercent: number | null; + bytesPerSecond: number | null; isEnabled: boolean; } @@ -23,6 +27,10 @@ export function useUpdateView(): UpdateView { return useStore(updateStore, (state) => ({ status: state.status, version: state.version, + availableVersion: state.availableVersion, + releaseNotes: state.releaseNotes, + downloadPercent: state.downloadPercent, + bytesPerSecond: state.bytesPerSecond, isEnabled: state.isEnabled, })); } diff --git a/packages/ui/src/features/updates/whatsNewStore.ts b/packages/ui/src/features/updates/whatsNewStore.ts new file mode 100644 index 0000000000..967c2d90c0 --- /dev/null +++ b/packages/ui/src/features/updates/whatsNewStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +interface WhatsNewStore { + isOpen: boolean; + open: () => void; + close: () => void; +} + +export const useWhatsNewStore = create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), +})); diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index eb3d99f5de..59099f8aad 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -30,6 +30,8 @@ import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOr import { RemoteBranchCheckoutDialog } from "@posthog/ui/features/task-detail/components/RemoteBranchCheckoutDialog"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { TourOverlay } from "@posthog/ui/features/tour/components/TourOverlay"; +import { UpdateAvailableModal } from "@posthog/ui/features/updates/UpdateAvailableModal"; +import { WhatsNewModal } from "@posthog/ui/features/updates/WhatsNewModal"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; import LogosLandscape from "@posthog/ui/primitives/Logo"; import { useAppView } from "@posthog/ui/router/useAppView"; @@ -318,6 +320,8 @@ function RootLayout() { onToggleShortcutsSheet={toggleShortcutsSheet} /> {billingEnabled && } + + {billingEnabled && } + + {import.meta.env.DEV && ( @@ -388,6 +394,8 @@ function RootLayout() { /> {billingEnabled && } + + {import.meta.env.DEV && ( diff --git a/packages/workspace-server/src/services/github-releases/github-releases.module.ts b/packages/workspace-server/src/services/github-releases/github-releases.module.ts new file mode 100644 index 0000000000..14c9af347a --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/github-releases.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { GitHubReleasesService } from "./github-releases"; +import { GITHUB_RELEASES_SERVICE } from "./identifiers"; + +export const githubReleasesModule = new ContainerModule(({ bind }) => { + bind(GITHUB_RELEASES_SERVICE).to(GitHubReleasesService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/github-releases/github-releases.test.ts b/packages/workspace-server/src/services/github-releases/github-releases.test.ts new file mode 100644 index 0000000000..cba0f726be --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/github-releases.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GitHubReleasesService } from "./github-releases"; + +const sampleReleases = [ + { + tag_name: "v1.2.0", + name: "v1.2.0", + body: "## Notes\n- thing", + draft: false, + prerelease: false, + published_at: "2026-06-20T00:00:00Z", + html_url: "https://github.com/PostHog/code/releases/tag/v1.2.0", + }, + { + tag_name: "v1.1.0", + name: "", + body: null, + draft: false, + prerelease: true, + published_at: "2026-06-10T00:00:00Z", + html_url: "https://github.com/PostHog/code/releases/tag/v1.1.0", + }, + { + tag_name: "v1.3.0-draft", + name: "draft", + body: "x", + draft: true, + prerelease: false, + published_at: null, + html_url: "https://github.com/PostHog/code/releases/tag/v1.3.0-draft", + }, +]; + +describe("GitHubReleasesService", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => sampleReleases, + })); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("maps releases, strips the v prefix and drops drafts", async () => { + const service = new GitHubReleasesService(); + const { releases } = await service.listReleases(); + + expect(releases).toHaveLength(2); + expect(releases[0]).toEqual({ + version: "1.2.0", + name: "v1.2.0", + notes: "## Notes\n- thing", + date: "2026-06-20T00:00:00Z", + isPrerelease: false, + htmlUrl: "https://github.com/PostHog/code/releases/tag/v1.2.0", + }); + // empty name falls back to the tag; null body becomes an empty string + expect(releases[1]).toMatchObject({ + version: "1.1.0", + name: "v1.1.0", + notes: "", + isPrerelease: true, + }); + }); + + it("caches results within the TTL", async () => { + const service = new GitHubReleasesService(); + await service.listReleases(); + await service.listReleases(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws on non-ok responses", async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }); + const service = new GitHubReleasesService(); + await expect(service.listReleases()).rejects.toThrow(); + }); +}); diff --git a/packages/workspace-server/src/services/github-releases/github-releases.ts b/packages/workspace-server/src/services/github-releases/github-releases.ts new file mode 100644 index 0000000000..fa1c787719 --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/github-releases.ts @@ -0,0 +1,45 @@ +import { injectable } from "inversify"; +import { githubReleasesApiResponse, type ListReleasesOutput } from "./schemas"; + +const RELEASES_URL = + "https://api.github.com/repos/PostHog/code/releases?per_page=30"; +const CACHE_TTL_MS = 10 * 60_000; +const FETCH_TIMEOUT_MS = 10_000; + +@injectable() +export class GitHubReleasesService { + private cache: { fetchedAt: number; data: ListReleasesOutput } | null = null; + + async listReleases(): Promise { + if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { + return this.cache.data; + } + + const response = await fetch(RELEASES_URL, { + headers: { Accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`GitHub releases fetch failed: ${response.status}`); + } + + const parsed = githubReleasesApiResponse.parse(await response.json()); + const releases = parsed + .filter((release) => !release.draft) + .map((release) => ({ + version: release.tag_name.replace(/^v/, ""), + name: + release.name && release.name.length > 0 + ? release.name + : release.tag_name, + notes: release.body ?? "", + date: release.published_at, + isPrerelease: release.prerelease, + htmlUrl: release.html_url, + })); + + const data: ListReleasesOutput = { releases }; + this.cache = { fetchedAt: Date.now(), data }; + return data; + } +} diff --git a/packages/workspace-server/src/services/github-releases/identifiers.ts b/packages/workspace-server/src/services/github-releases/identifiers.ts new file mode 100644 index 0000000000..7ee3c1a88c --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/identifiers.ts @@ -0,0 +1,3 @@ +export const GITHUB_RELEASES_SERVICE = Symbol.for( + "posthog.workspace.githubReleasesService", +); diff --git a/packages/workspace-server/src/services/github-releases/schemas.ts b/packages/workspace-server/src/services/github-releases/schemas.ts new file mode 100644 index 0000000000..94181ca921 --- /dev/null +++ b/packages/workspace-server/src/services/github-releases/schemas.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const githubReleaseApiItem = z.object({ + tag_name: z.string(), + name: z.string().nullable(), + body: z.string().nullable(), + draft: z.boolean(), + prerelease: z.boolean(), + published_at: z.string().nullable(), + html_url: z.string(), +}); + +export const githubReleasesApiResponse = z.array(githubReleaseApiItem); + +export const releaseItem = z.object({ + version: z.string(), + name: z.string(), + notes: z.string(), + date: z.string().nullable(), + isPrerelease: z.boolean(), + htmlUrl: z.string(), +}); + +export const listReleasesOutput = z.object({ + releases: z.array(releaseItem), +}); + +export type ReleaseItem = z.infer; +export type ListReleasesOutput = z.infer; From c3ff52f10a2010283f6f3c12a9466c39b9f396cc Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 20:30:41 -0700 Subject: [PATCH 2/8] Update WhatsNewModal.tsx --- packages/ui/src/features/updates/WhatsNewModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx index 636d6ca247..771d70ba22 100644 --- a/packages/ui/src/features/updates/WhatsNewModal.tsx +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -26,7 +26,7 @@ export function WhatsNewModal() { const isOpen = useWhatsNewStore((state) => state.isOpen); const close = useWhatsNewStore((state) => state.close); const hostTRPC = useHostTRPC(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, error } = useQuery({ ...hostTRPC.githubReleases.list.queryOptions(), enabled: isOpen, }); @@ -59,7 +59,8 @@ export function WhatsNewModal() { ) : isError ? ( - Could not load releases. Please try again later. + Could not load releases:{" "} + {error instanceof Error ? error.message : String(error)} ) : releases.length === 0 ? ( From 4acc1522b703d29d26d231344b8b56154ec26a8a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 20:34:37 -0700 Subject: [PATCH 3/8] Update router.ts --- apps/code/src/main/trpc/router.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index ec493315df..31dabb003f 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -22,6 +22,7 @@ import { freeformGenRouter } from "@posthog/host-router/routers/freeform-gen.rou import { fsRouter } from "@posthog/host-router/routers/fs.router"; import { gitRouter } from "@posthog/host-router/routers/git.router"; import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router"; +import { githubReleasesRouter } from "@posthog/host-router/routers/github-releases.router"; import { handoffRouter } from "@posthog/host-router/routers/handoff.router"; import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router"; import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router"; @@ -74,6 +75,7 @@ export const trpcRouter = router({ fs: fsRouter, git: gitRouter, githubIntegration: githubIntegrationRouter, + githubReleases: githubReleasesRouter, handoff: handoffRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, From 9434cdea1a1cbca7b8f7fdaf7a0bda22b0036339 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 21:00:23 -0700 Subject: [PATCH 4/8] group changelog by day/week and move menu item --- .../sidebar/components/ProjectSwitcher.tsx | 10 +- .../ui/src/features/updates/WhatsNewModal.tsx | 109 ++++++++++----- .../src/features/updates/releaseNotes.test.ts | 70 ++++++++++ .../ui/src/features/updates/releaseNotes.ts | 129 ++++++++++++++++++ 4 files changed, 276 insertions(+), 42 deletions(-) create mode 100644 packages/ui/src/features/updates/releaseNotes.test.ts create mode 100644 packages/ui/src/features/updates/releaseNotes.ts diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index f80997dded..f74f0413e9 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -310,6 +310,11 @@ export function ProjectSwitcher({
+ + + View changelog + + @@ -340,11 +345,6 @@ export function ProjectSwitcher({ - - - View changelog - - Settings diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx index 771d70ba22..39f5a17b66 100644 --- a/packages/ui/src/features/updates/WhatsNewModal.tsx +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -1,5 +1,8 @@ import { useHostTRPC } from "@posthog/host-router/react"; -import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { + groupReleases, + mergeReleaseNotes, +} from "@posthog/ui/features/updates/releaseNotes"; import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; import { Badge, @@ -11,22 +14,33 @@ import { } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -function formatDate(date: string | null): string { - if (!date) return ""; - const parsed = new Date(date); - if (Number.isNaN(parsed.getTime())) return ""; - return parsed.toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }); +function ReleaseSection({ title, items }: { title: string; items: string[] }) { + if (items.length === 0) return null; + return ( + + + {title} + +
    + {items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ); } export function WhatsNewModal() { const isOpen = useWhatsNewStore((state) => state.isOpen); const close = useWhatsNewStore((state) => state.close); const hostTRPC = useHostTRPC(); - const { data, isLoading, isError, error } = useQuery({ + const { data, isLoading, isError } = useQuery({ ...hostTRPC.githubReleases.list.queryOptions(), enabled: isOpen, }); @@ -34,7 +48,7 @@ export function WhatsNewModal() { hostTRPC.os.getAppVersion.queryOptions(), ); - const releases = data?.releases ?? []; + const groups = groupReleases(data?.releases ?? []); return ( ) : isError ? ( - Could not load releases:{" "} - {error instanceof Error ? error.message : String(error)} + Could not load releases. Please try again later. - ) : releases.length === 0 ? ( + ) : groups.length === 0 ? ( No releases found. ) : ( - {releases.map((release, index) => ( - - - + {groups.map((group, index) => { + const { improved, fixed } = mergeReleaseNotes(group.releases); + const containsCurrent = currentVersion + ? group.releases.some( + (release) => release.version === currentVersion, + ) + : false; + return ( + 0 ? "border-gray-6 border-t pt-5" : undefined + } + > + - {release.name} + {group.label} - {index === 0 ? Latest : null} - {currentVersion === release.version ? ( + + {group.isLatest ? ( + Latest + ) : null} + {containsCurrent ? ( + + Current + + ) : null} - Current + {group.releases.length === 1 + ? group.releases[0].name + : `${group.releases.length} releases`} - ) : null} + - - {formatDate(release.date)} - + {improved.length === 0 && fixed.length === 0 ? ( + + No notable changes. + + ) : ( + + + + + )} - {release.notes ? ( - - ) : ( - - No release notes. - - )} - - ))} + ); + })} )} diff --git a/packages/ui/src/features/updates/releaseNotes.test.ts b/packages/ui/src/features/updates/releaseNotes.test.ts new file mode 100644 index 0000000000..744280b190 --- /dev/null +++ b/packages/ui/src/features/updates/releaseNotes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + groupReleases, + mergeReleaseNotes, + parseReleaseNotes, +} from "./releaseNotes"; + +describe("parseReleaseNotes", () => { + it("keeps change bullets, strips prefix + attribution, splits fix vs rest", () => { + const notes = [ + "## What's Changed", + "* feat(canvas): right-click a canvas by @alice in https://github.com/PostHog/code/pull/1", + "* fix(inbox): point link to docs by @bob in https://github.com/PostHog/code/pull/2", + '* Add "PostHog Web" button by @carol in https://github.com/PostHog/code/pull/3', + "* @newbie made their first contribution in https://example.com", + "**Full Changelog**: https://github.com/PostHog/code/compare/v1...v2", + ].join("\n"); + + expect(parseReleaseNotes(notes)).toEqual({ + improved: ["Right-click a canvas", 'Add "PostHog Web" button'], + fixed: ["Point link to docs"], + }); + }); +}); + +describe("mergeReleaseNotes", () => { + it("merges across releases and dedupes", () => { + const releases = [ + { name: "v2", version: "2", date: null, notes: "* fix: a\n* feat: b" }, + { name: "v1", version: "1", date: null, notes: "* fix: a\n* feat: c" }, + ]; + expect(mergeReleaseNotes(releases)).toEqual({ + improved: ["B", "C"], + fixed: ["A"], + }); + }); +}); + +describe("groupReleases", () => { + const now = Date.parse("2026-06-20T12:00:00Z"); + const mk = (name: string, date: string) => ({ + name, + version: name.replace(/^v/, ""), + date, + notes: "", + }); + + it("buckets recent releases by day and older ones by week", () => { + const groups = groupReleases( + [ + mk("v0.55.14", "2026-06-20T12:00:00Z"), + mk("v0.55.13", "2026-06-19T12:00:00Z"), + mk("v0.55.12", "2026-06-19T09:00:00Z"), + mk("v0.55.5", "2026-06-12T12:00:00Z"), + mk("v0.55.4", "2026-06-10T12:00:00Z"), + ], + now, + 3, + ); + + expect(groups).toHaveLength(3); + expect(groups[0].key.startsWith("day-")).toBe(true); + expect(groups[0].releases).toHaveLength(1); + expect(groups[0].isLatest).toBe(true); + expect(groups[1].key.startsWith("day-")).toBe(true); + expect(groups[1].releases).toHaveLength(2); + expect(groups[2].key.startsWith("week-")).toBe(true); + expect(groups[2].releases).toHaveLength(2); + }); +}); diff --git a/packages/ui/src/features/updates/releaseNotes.ts b/packages/ui/src/features/updates/releaseNotes.ts new file mode 100644 index 0000000000..22c25ea3bd --- /dev/null +++ b/packages/ui/src/features/updates/releaseNotes.ts @@ -0,0 +1,129 @@ +export interface CategorizedNotes { + improved: string[]; + fixed: string[]; +} + +export interface ReleaseLike { + name: string; + version: string; + notes: string; + date: string | null; +} + +export interface ReleaseGroup { + key: string; + label: string; + releases: ReleaseLike[]; + isLatest: boolean; +} + +// Recent releases stay broken out by day; anything older is bucketed by week so +// the timeline stays scannable when we ship a lot. +export const RECENT_DAYS = 3; +const DAY_MS = 86_400_000; + +// Release notes are GitHub auto-generated: a "What's Changed" bullet list of PR +// titles (conventional-commit prefixed), plus contributor and "Full Changelog" +// noise. Keep only the change bullets, strip the prefix and "by @user in " +// attribution, and bucket `fix` into Fixed and everything else into Improved. +export function parseReleaseNotes(notes: string): CategorizedNotes { + const improved: string[] = []; + const fixed: string[] = []; + for (const rawLine of notes.split("\n")) { + const bullet = rawLine.trim().match(/^[-*•]\s+(.*)$/); + if (!bullet) continue; + let text = bullet[1].trim(); + if (text.startsWith("@")) continue; // "New Contributors" lines + text = text.replace(/\s+by\s+@\S+\s+in\s+\S+.*$/i, "").trim(); + const conventional = text.match(/^([a-z]+)(?:\([^)]*\))?!?:\s*(.*)$/i); + let isFix = false; + if (conventional) { + isFix = conventional[1].toLowerCase() === "fix"; + text = conventional[2].trim(); + } else { + isFix = /^fix(ed|es)?\b/i.test(text); + } + if (text.length === 0) continue; + text = text.charAt(0).toUpperCase() + text.slice(1); + (isFix ? fixed : improved).push(text); + } + return { improved, fixed }; +} + +export function mergeReleaseNotes(releases: ReleaseLike[]): CategorizedNotes { + const improved: string[] = []; + const fixed: string[] = []; + for (const release of releases) { + const parsed = parseReleaseNotes(release.notes); + improved.push(...parsed.improved); + fixed.push(...parsed.fixed); + } + return { + improved: Array.from(new Set(improved)), + fixed: Array.from(new Set(fixed)), + }; +} + +function dayLabel(date: Date): string { + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +function weekLabel(date: Date): string { + const monday = new Date(date); + monday.setDate(date.getDate() - ((date.getDay() + 6) % 7)); + return `Week of ${monday.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })}`; +} + +function weekKey(date: Date): string { + const monday = new Date(date); + monday.setDate(date.getDate() - ((date.getDay() + 6) % 7)); + return `week-${monday.toDateString()}`; +} + +// Groups newest-first releases: each of the last `recentDays` days is its own +// group; everything older is grouped into its calendar week. +export function groupReleases( + releases: ReleaseLike[], + now: number = Date.now(), + recentDays: number = RECENT_DAYS, +): ReleaseGroup[] { + const recentCutoff = now - recentDays * DAY_MS; + const order: string[] = []; + const map = new Map(); + + releases.forEach((release, index) => { + const time = release.date ? Date.parse(release.date) : Number.NaN; + const dated = !Number.isNaN(time); + let key: string; + let label: string; + if (dated && time >= recentCutoff) { + const date = new Date(time); + key = `day-${date.toDateString()}`; + label = dayLabel(date); + } else if (dated) { + const date = new Date(time); + key = weekKey(date); + label = weekLabel(date); + } else { + key = "earlier"; + label = "Earlier"; + } + + let group = map.get(key); + if (!group) { + group = { key, label, releases: [], isLatest: index === 0 }; + map.set(key, group); + order.push(key); + } + group.releases.push(release); + }); + + return order.map((key) => map.get(key) as ReleaseGroup); +} From 0df0750bf7dd7defe85f3fb7a8b014b3ae8f2ae5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 21:01:56 -0700 Subject: [PATCH 5/8] add close button to changelog modal --- .../ui/src/features/updates/WhatsNewModal.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx index 39f5a17b66..4d61ca170d 100644 --- a/packages/ui/src/features/updates/WhatsNewModal.tsx +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -1,3 +1,4 @@ +import { X } from "@phosphor-icons/react"; import { useHostTRPC } from "@posthog/host-router/react"; import { groupReleases, @@ -8,6 +9,7 @@ import { Badge, Dialog, Flex, + IconButton, ScrollArea, Spinner, Text, @@ -58,13 +60,20 @@ export function WhatsNewModal() { }} > - - What's New - - - Release history and recent improvements - - + + + What's New + + + Release history and recent improvements + + + + + + + + {isLoading ? ( From cacfedacba027281985b15a625f0f630ab9e0d4b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 22:10:13 -0700 Subject: [PATCH 6/8] Update WhatsNewModal.tsx --- .../ui/src/features/updates/WhatsNewModal.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx index 4d61ca170d..077277c5bd 100644 --- a/packages/ui/src/features/updates/WhatsNewModal.tsx +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -11,7 +11,7 @@ import { Flex, IconButton, ScrollArea, - Spinner, + Skeleton, Text, } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; @@ -38,6 +38,27 @@ function ReleaseSection({ title, items }: { title: string; items: string[] }) { ); } +function ChangelogSkeleton() { + return ( + + {["a", "b", "c"].map((key) => ( + + + + + + + + + + + + + ))} + + ); +} + export function WhatsNewModal() { const isOpen = useWhatsNewStore((state) => state.isOpen); const close = useWhatsNewStore((state) => state.close); @@ -77,9 +98,7 @@ export function WhatsNewModal() { {isLoading ? ( - - - + ) : isError ? ( Could not load releases. Please try again later. From f7ef353d06b763ae657b62db9ec6c4f3efa18c9a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 22:16:47 -0700 Subject: [PATCH 7/8] merge duplicate useUpdateView subscription --- packages/ui/src/features/sidebar/components/UpdateBanner.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/UpdateBanner.tsx b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx index b09879396f..ab819a70f9 100644 --- a/packages/ui/src/features/sidebar/components/UpdateBanner.tsx +++ b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx @@ -12,9 +12,8 @@ interface UpdateBannerProps { } export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { - const { status, version, availableVersion, downloadPercent } = + const { status, version, availableVersion, downloadPercent, isEnabled } = useUpdateView(); - const { isEnabled } = useUpdateView(); const installUpdate = useInstallUpdate(); const openModal = useUpdateModalStore((state) => state.open); From 97fb702f2cd284b118f68d928faa4161518ae050 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 02:21:53 -0700 Subject: [PATCH 8/8] fix update banner re-check and auto-download sync --- .../platform-adapters/electron-updater.ts | 4 +- packages/core/src/updates/updates.test.ts | 48 ++++++++++++++++ packages/core/src/updates/updates.ts | 12 ++++ .../settings/sections/GeneralSettings.tsx | 6 +- .../features/updates/UpdateAvailableModal.tsx | 5 +- .../ui/src/features/updates/releaseNotes.ts | 26 +++++---- .../src/features/updates/updateModalStore.ts | 14 +---- .../ui/src/features/updates/whatsNewStore.ts | 14 +---- packages/ui/src/utils/createDialogStore.ts | 15 +++++ .../github-releases/github-releases.test.ts | 14 +++++ .../github-releases/github-releases.ts | 55 +++++++++++-------- 11 files changed, 146 insertions(+), 67 deletions(-) create mode 100644 packages/ui/src/utils/createDialogStore.ts diff --git a/apps/code/src/main/platform-adapters/electron-updater.ts b/apps/code/src/main/platform-adapters/electron-updater.ts index 6f22e61ce6..c8d9ff3052 100644 --- a/apps/code/src/main/platform-adapters/electron-updater.ts +++ b/apps/code/src/main/platform-adapters/electron-updater.ts @@ -45,11 +45,11 @@ export class ElectronUpdater implements IUpdater { } public check(): void { - void autoUpdater.checkForUpdates(); + void autoUpdater.checkForUpdates().catch(() => undefined); } public download(): void { - void autoUpdater.downloadUpdate(); + void autoUpdater.downloadUpdate().catch(() => undefined); } public quitAndInstall(): void { diff --git a/packages/core/src/updates/updates.test.ts b/packages/core/src/updates/updates.test.ts index e84cd0e09b..f410c388af 100644 --- a/packages/core/src/updates/updates.test.ts +++ b/packages/core/src/updates/updates.test.ts @@ -722,6 +722,54 @@ describe("UpdatesService", () => { }); }); + describe("available update guards", () => { + it("does not re-check or clear the banner on periodic checks while available", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: "Notes", + }); + expect(service.getStatus()).toMatchObject({ + available: true, + availableVersion: "v2.0.0", + }); + + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + mockUpdater.check.mockClear(); + + const result = service.checkForUpdates("periodic"); + + expect(result).toEqual({ success: true }); + expect(mockUpdater.check).not.toHaveBeenCalled(); + expect(statusHandler).not.toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ + available: true, + availableVersion: "v2.0.0", + }); + }); + + it("starts the download when auto-download is enabled while available", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.({ + version: "v2.0.0", + releaseNotes: null, + }); + expect(service.getStatus()).toMatchObject({ available: true }); + + mockUpdater.download.mockClear(); + service.setAutoDownloadEnabled(true); + + expect(mockUpdater.download).toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ + downloading: true, + availableVersion: "v2.0.0", + }); + }); + }); + describe("check timeout", () => { beforeEach(async () => { await initializeService(service); diff --git a/packages/core/src/updates/updates.ts b/packages/core/src/updates/updates.ts index 74ceff71e7..ee4e0fd9c9 100644 --- a/packages/core/src/updates/updates.ts +++ b/packages/core/src/updates/updates.ts @@ -128,6 +128,10 @@ export class UpdatesService extends TypedEventEmitter { this.updater.setAutoDownload(enabled); } this.log.info("Auto-download preference updated", { enabled }); + + if (enabled && this.state === "available") { + this.requestDownload(); + } } requestDownload(): void { @@ -199,6 +203,14 @@ export class UpdatesService extends TypedEventEmitter { return { success: true }; } + if (source === "periodic" && this.state === "available") { + this.logStateTransition(this.state, { + source, + reason: "periodic check skipped because an update is already available", + }); + return { success: true }; + } + if (this.state === "checking" || this.state === "downloading") { return { success: false, diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 83c4c7aa3e..19391dd6a4 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -61,9 +61,6 @@ export function GeneralSettings() { const { data: updatesEnabled } = useQuery( hostTRPC.updates.isEnabled.queryOptions(), ); - const autoDownloadMutation = useMutation( - hostTRPC.updates.setAutoDownload.mutationOptions(), - ); useEffect(() => { if (serverPreventSleep !== undefined) { @@ -92,9 +89,8 @@ export function GeneralSettings() { old_value: !checked, }); setDownloadUpdatesAutomatically(checked); - autoDownloadMutation.mutate({ enabled: checked }); }, - [setDownloadUpdatesAutomatically, autoDownloadMutation], + [setDownloadUpdatesAutomatically], ); // Chat state diff --git a/packages/ui/src/features/updates/UpdateAvailableModal.tsx b/packages/ui/src/features/updates/UpdateAvailableModal.tsx index fcb6201935..7053eb8e4a 100644 --- a/packages/ui/src/features/updates/UpdateAvailableModal.tsx +++ b/packages/ui/src/features/updates/UpdateAvailableModal.tsx @@ -126,7 +126,10 @@ export function UpdateAvailableModal() { Downloading... ) : ( - diff --git a/packages/ui/src/features/updates/releaseNotes.ts b/packages/ui/src/features/updates/releaseNotes.ts index 22c25ea3bd..1a8cfd3241 100644 --- a/packages/ui/src/features/updates/releaseNotes.ts +++ b/packages/ui/src/features/updates/releaseNotes.ts @@ -72,19 +72,21 @@ function dayLabel(date: Date): string { }); } -function weekLabel(date: Date): string { +function mondayOf(date: Date): Date { const monday = new Date(date); monday.setDate(date.getDate() - ((date.getDay() + 6) % 7)); - return `Week of ${monday.toLocaleDateString(undefined, { + return monday; +} + +function weekLabel(date: Date): string { + return `Week of ${mondayOf(date).toLocaleDateString(undefined, { month: "short", day: "numeric", })}`; } function weekKey(date: Date): string { - const monday = new Date(date); - monday.setDate(date.getDate() - ((date.getDay() + 6) % 7)); - return `week-${monday.toDateString()}`; + return `week-${mondayOf(date).toDateString()}`; } // Groups newest-first releases: each of the last `recentDays` days is its own @@ -95,10 +97,9 @@ export function groupReleases( recentDays: number = RECENT_DAYS, ): ReleaseGroup[] { const recentCutoff = now - recentDays * DAY_MS; - const order: string[] = []; const map = new Map(); - releases.forEach((release, index) => { + for (const release of releases) { const time = release.date ? Date.parse(release.date) : Number.NaN; const dated = !Number.isNaN(time); let key: string; @@ -118,12 +119,15 @@ export function groupReleases( let group = map.get(key); if (!group) { - group = { key, label, releases: [], isLatest: index === 0 }; + group = { key, label, releases: [], isLatest: false }; map.set(key, group); - order.push(key); } group.releases.push(release); - }); + } - return order.map((key) => map.get(key) as ReleaseGroup); + const groups = Array.from(map.values()); + if (groups.length > 0) { + groups[0].isLatest = true; + } + return groups; } diff --git a/packages/ui/src/features/updates/updateModalStore.ts b/packages/ui/src/features/updates/updateModalStore.ts index f1ca6af5fa..16d4d94cf5 100644 --- a/packages/ui/src/features/updates/updateModalStore.ts +++ b/packages/ui/src/features/updates/updateModalStore.ts @@ -1,13 +1,3 @@ -import { create } from "zustand"; +import { createDialogStore } from "@posthog/ui/utils/createDialogStore"; -interface UpdateModalStore { - isOpen: boolean; - open: () => void; - close: () => void; -} - -export const useUpdateModalStore = create((set) => ({ - isOpen: false, - open: () => set({ isOpen: true }), - close: () => set({ isOpen: false }), -})); +export const useUpdateModalStore = createDialogStore(); diff --git a/packages/ui/src/features/updates/whatsNewStore.ts b/packages/ui/src/features/updates/whatsNewStore.ts index 967c2d90c0..6b14f9e1d2 100644 --- a/packages/ui/src/features/updates/whatsNewStore.ts +++ b/packages/ui/src/features/updates/whatsNewStore.ts @@ -1,13 +1,3 @@ -import { create } from "zustand"; +import { createDialogStore } from "@posthog/ui/utils/createDialogStore"; -interface WhatsNewStore { - isOpen: boolean; - open: () => void; - close: () => void; -} - -export const useWhatsNewStore = create((set) => ({ - isOpen: false, - open: () => set({ isOpen: true }), - close: () => set({ isOpen: false }), -})); +export const useWhatsNewStore = createDialogStore(); diff --git a/packages/ui/src/utils/createDialogStore.ts b/packages/ui/src/utils/createDialogStore.ts new file mode 100644 index 0000000000..298404a768 --- /dev/null +++ b/packages/ui/src/utils/createDialogStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +export interface DialogStore { + isOpen: boolean; + open: () => void; + close: () => void; +} + +export function createDialogStore() { + return create((set) => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + })); +} diff --git a/packages/workspace-server/src/services/github-releases/github-releases.test.ts b/packages/workspace-server/src/services/github-releases/github-releases.test.ts index cba0f726be..b1e101b123 100644 --- a/packages/workspace-server/src/services/github-releases/github-releases.test.ts +++ b/packages/workspace-server/src/services/github-releases/github-releases.test.ts @@ -81,4 +81,18 @@ describe("GitHubReleasesService", () => { const service = new GitHubReleasesService(); await expect(service.listReleases()).rejects.toThrow(); }); + + it("serves stale cache when a later refetch fails", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0); + const service = new GitHubReleasesService(); + const first = await service.listReleases(); + + nowSpy.mockReturnValue(11 * 60_000); + fetchMock.mockResolvedValueOnce({ ok: false, status: 500 }); + const second = await service.listReleases(); + + expect(second).toEqual(first); + expect(fetchMock).toHaveBeenCalledTimes(2); + nowSpy.mockRestore(); + }); }); diff --git a/packages/workspace-server/src/services/github-releases/github-releases.ts b/packages/workspace-server/src/services/github-releases/github-releases.ts index fa1c787719..11c92132dc 100644 --- a/packages/workspace-server/src/services/github-releases/github-releases.ts +++ b/packages/workspace-server/src/services/github-releases/github-releases.ts @@ -15,31 +15,38 @@ export class GitHubReleasesService { return this.cache.data; } - const response = await fetch(RELEASES_URL, { - headers: { Accept: "application/vnd.github+json" }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - if (!response.ok) { - throw new Error(`GitHub releases fetch failed: ${response.status}`); - } + try { + const response = await fetch(RELEASES_URL, { + headers: { Accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`GitHub releases fetch failed: ${response.status}`); + } - const parsed = githubReleasesApiResponse.parse(await response.json()); - const releases = parsed - .filter((release) => !release.draft) - .map((release) => ({ - version: release.tag_name.replace(/^v/, ""), - name: - release.name && release.name.length > 0 - ? release.name - : release.tag_name, - notes: release.body ?? "", - date: release.published_at, - isPrerelease: release.prerelease, - htmlUrl: release.html_url, - })); + const parsed = githubReleasesApiResponse.parse(await response.json()); + const releases = parsed + .filter((release) => !release.draft) + .map((release) => ({ + version: release.tag_name.replace(/^v/, ""), + name: + release.name && release.name.length > 0 + ? release.name + : release.tag_name, + notes: release.body ?? "", + date: release.published_at, + isPrerelease: release.prerelease, + htmlUrl: release.html_url, + })); - const data: ListReleasesOutput = { releases }; - this.cache = { fetchedAt: Date.now(), data }; - return data; + const data: ListReleasesOutput = { releases }; + this.cache = { fetchedAt: Date.now(), data }; + return data; + } catch (error) { + if (this.cache) { + return this.cache.data; + } + throw error; + } } }