add release monitor and ui toast

This commit is contained in:
Shantur Rathore
2025-12-07 00:55:10 +00:00
parent 87da8ee9f8
commit 3e72b83393
8 changed files with 282 additions and 4 deletions

View File

@@ -167,6 +167,7 @@ export type WorkspaceEventType =
| "instance.dataChanged" | "instance.dataChanged"
| "instance.event" | "instance.event"
| "instance.eventStatus" | "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload = export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor } | { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -179,6 +180,7 @@ export type WorkspaceEventPayload =
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress { export interface NetworkAddress {
ip: string ip: string
@@ -187,6 +189,15 @@ export interface NetworkAddress {
url: string url: string
} }
export interface LatestReleaseInfo {
version: string
tag: string
url: string
channel: "stable" | "dev"
publishedAt?: string
notes?: string
}
export interface ServerMeta { export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */ /** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string httpBaseUrl: string
@@ -204,6 +215,8 @@ export interface ServerMeta {
workspaceRoot: string workspaceRoot: string
/** Reachable addresses for this server, external first. */ /** Reachable addresses for this server, external first. */
addresses: NetworkAddress[] addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */
latestRelease?: LatestReleaseInfo
} }
export type { export type {

View File

@@ -29,6 +29,7 @@ export class EventBus extends EventEmitter {
this.on("instance.dataChanged", handler) this.on("instance.dataChanged", handler)
this.on("instance.event", handler) this.on("instance.event", handler)
this.on("instance.eventStatus", handler) this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", handler)
return () => { return () => {
this.off("workspace.created", handler) this.off("workspace.created", handler)
this.off("workspace.started", handler) this.off("workspace.started", handler)
@@ -40,6 +41,7 @@ export class EventBus extends EventEmitter {
this.off("instance.dataChanged", handler) this.off("instance.dataChanged", handler)
this.off("instance.event", handler) this.off("instance.event", handler)
this.off("instance.eventStatus", handler) this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
} }
} }
} }

View File

@@ -17,6 +17,7 @@ import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events" import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher" import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -149,6 +150,19 @@ async function main() {
addresses: [], addresses: [],
} }
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
}
},
})
const server = createHttpServer({ const server = createHttpServer({
host: options.host, host: options.host,
port: options.port, port: options.port,
@@ -196,6 +210,8 @@ async function main() {
logger.error({ err: error }, "Workspace manager shutdown failed") logger.error({ err: error }, "Workspace manager shutdown failed")
} }
releaseMonitor.stop()
logger.info("Exiting process") logger.info("Exiting process")
process.exit(0) process.exit(0)
} }

View File

@@ -0,0 +1,141 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
interface ReleaseMonitorOptions {
currentVersion: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
}
interface GithubReleaseResponse {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
}
interface NormalizedVersion {
major: number
minor: number
patch: number
prerelease: string | null
}
export interface ReleaseMonitor {
stop(): void
}
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
let stopped = false
const refreshRelease = async () => {
if (stopped) return
try {
const release = await fetchLatestRelease(options)
options.onUpdate(release)
} catch (error) {
options.logger.warn({ err: error }, "Failed to refresh release information")
}
}
void refreshRelease()
return {
stop() {
stopped = true
},
}
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Release API responded with ${response.status}`)
}
const json = (await response.json()) as GithubReleaseResponse
const tagFromServer = json.tag_name || json.name
if (!tagFromServer) {
return null
}
const normalizedVersion = stripTagPrefix(tagFromServer)
if (!normalizedVersion) {
return null
}
const current = parseVersion(options.currentVersion)
const remote = parseVersion(normalizedVersion)
if (compareVersions(remote, current) <= 0) {
return null
}
return {
version: normalizedVersion,
tag: tagFromServer,
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
publishedAt: json.published_at ?? json.created_at,
notes: json.body,
}
}
function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()
if (!trimmed) return null
return trimmed.replace(/^v/i, "")
}
function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0"
const [core, prerelease = null] = normalized.split("-", 2)
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0
})
return {
major,
minor,
patch,
prerelease,
}
}
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
if (a.major !== b.major) {
return a.major > b.major ? 1 : -1
}
if (a.minor !== b.minor) {
return a.minor > b.minor ? 1 : -1
}
if (a.patch !== b.patch) {
return a.patch > b.patch ? 1 : -1
}
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
if (aPre === bPre) {
return 0
}
if (!aPre) {
return 1
}
if (!bPre) {
return -1
}
return aPre.localeCompare(bPre)
}

View File

@@ -13,6 +13,7 @@ import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { import {
hasInstances, hasInstances,
isSelectingFolder, isSelectingFolder,
@@ -67,6 +68,10 @@ const App: Component = () => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
}) })
createEffect(() => {
initReleaseNotifications()
})
const activeInstance = createMemo(() => getActiveInstance()) const activeInstance = createMemo(() => getActiveInstance())
const activeSessionIdForInstance = createMemo(() => { const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance() const instance = activeInstance()
@@ -352,9 +357,8 @@ const App: Component = () => {
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} /> <RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog /> <AlertDialog />
<Toaster
<Toaster
position="top-right" position="top-right"
gutter={16} gutter={16}
toastOptions={{ toastOptions={{
@@ -367,4 +371,5 @@ const App: Component = () => {
) )
} }
export default App export default App

View File

@@ -8,6 +8,7 @@ import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/nat
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps { interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean isLoading?: boolean
@@ -253,6 +254,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col"> <div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show <Show
when={folders().length > 0} when={folders().length > 0}
fallback={ fallback={
<div class="panel panel-empty-state flex-1"> <div class="panel panel-empty-state flex-1">

View File

@@ -2,11 +2,23 @@ import toast from "solid-toast"
export type ToastVariant = "info" | "success" | "warning" | "error" export type ToastVariant = "info" | "success" | "warning" | "error"
export type ToastHandle = {
id: string
dismiss: () => void
}
type ToastPosition = "top-left" | "top-right" | "top-center" | "bottom-left" | "bottom-right" | "bottom-center"
export type ToastPayload = { export type ToastPayload = {
title?: string title?: string
message: string message: string
variant: ToastVariant variant: ToastVariant
duration?: number duration?: number
position?: ToastPosition
action?: {
label: string
href: string
}
} }
const variantAccent: Record< const variantAccent: Record<
@@ -44,11 +56,11 @@ const variantAccent: Record<
}, },
} }
export function showToastNotification(payload: ToastPayload) { export function showToastNotification(payload: ToastPayload): ToastHandle {
const accent = variantAccent[payload.variant] const accent = variantAccent[payload.variant]
const duration = payload.duration ?? 10000 const duration = payload.duration ?? 10000
toast.custom( const id = toast.custom(
() => ( () => (
<div class={`pointer-events-auto w-[320px] max-w-[360px] rounded-lg border px-4 py-3 shadow-xl ${accent.container}`}> <div class={`pointer-events-auto w-[320px] max-w-[360px] rounded-lg border px-4 py-3 shadow-xl ${accent.container}`}>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@@ -56,16 +68,32 @@ export function showToastNotification(payload: ToastPayload) {
<div class="flex-1 text-sm leading-snug"> <div class="flex-1 text-sm leading-snug">
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>} {payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p> <p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
{payload.action && (
<a
class="mt-3 inline-flex items-center text-xs font-semibold uppercase tracking-wide text-sky-300 hover:text-sky-200"
href={payload.action.href}
target="_blank"
rel="noreferrer noopener"
>
{payload.action.label}
</a>
)}
</div> </div>
</div> </div>
</div> </div>
), ),
{ {
duration, duration,
position: payload.position ?? "top-right",
ariaProps: { ariaProps: {
role: "status", role: "status",
"aria-live": "polite", "aria-live": "polite",
}, },
}, },
) )
return {
id,
dismiss: () => toast.dismiss(id),
}
} }

View File

@@ -0,0 +1,70 @@
import { createSignal } from "solid-js"
import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types"
import { getServerMeta } from "../lib/server-meta"
import { serverEvents } from "../lib/server-events"
import { showToastNotification, ToastHandle } from "../lib/notifications"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
const [availableRelease, setAvailableRelease] = createSignal<LatestReleaseInfo | null>(null)
let initialized = false
let activeToast: ToastHandle | null = null
function dismissActiveToast() {
if (activeToast) {
activeToast.dismiss()
activeToast = null
}
}
export function initReleaseNotifications() {
if (initialized) {
return
}
initialized = true
void refreshFromMeta()
serverEvents.on("app.releaseAvailable", (event) => {
const typedEvent = event as Extract<WorkspaceEventPayload, { type: "app.releaseAvailable" }>
applyRelease(typedEvent.release)
})
}
async function refreshFromMeta() {
try {
const meta = await getServerMeta(true)
if (meta.latestRelease) {
applyRelease(meta.latestRelease)
}
} catch (error) {
log.warn("Unable to load server metadata for release info", error)
}
}
function applyRelease(release: LatestReleaseInfo | null | undefined) {
if (!release) {
setAvailableRelease(null)
dismissActiveToast()
return
}
setAvailableRelease(release)
dismissActiveToast()
activeToast = showToastNotification({
title: `CodeNomad ${release.version}`,
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
variant: "info",
duration: Number.POSITIVE_INFINITY,
position: "bottom-right",
action: {
label: "View release",
href: release.url,
},
})
}
export function useAvailableRelease() {
return availableRelease
}