Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
5a72117374 feat(runtimes): proactively notify users about daemon updates
Add red badge dots to the sidebar "Runtimes" item and individual runtime
list items when a newer CLI version is available on GitHub. This makes
update availability visible without requiring users to navigate into the
runtime detail page.

- Extract version utilities (fetchLatestVersion, isNewer, getCliVersion)
  into shared features/runtimes/version.ts
- Add latestCliVersionOptions TanStack Query in core/runtimes/queries.ts
- Add RuntimeUpdateDot component in sidebar showing when any runtime
  needs an update
- Show red dot + "Update available" text on runtime list items that
  are outdated
2026-04-08 17:32:56 +08:00
6 changed files with 114 additions and 55 deletions

View File

@@ -19,6 +19,8 @@ import {
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { runtimeListOptions, latestCliVersionOptions } from "@core/runtimes/queries";
import { runtimeNeedsUpdate } from "@/features/runtimes/version";
import {
Sidebar,
SidebarContent,
@@ -67,6 +69,14 @@ function DraftDot() {
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
function RuntimeUpdateDot({ wsId }: { wsId: string }) {
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const { data: latestVersion } = useQuery(latestCliVersionOptions());
const hasUpdates = runtimes.some((r) => runtimeNeedsUpdate(r, latestVersion ?? null));
if (!hasUpdates) return null;
return <span className="ml-auto size-2 rounded-full bg-destructive" />;
}
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
@@ -224,6 +234,9 @@ export function AppSidebar() {
>
<item.icon />
<span>{item.label}</span>
{item.label === "Runtimes" && wsId && (
<RuntimeUpdateDot wsId={wsId} />
)}
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -1,5 +1,6 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { fetchLatestVersion } from "@/features/runtimes/version";
export const runtimeKeys = {
all: (wsId: string) => ["runtimes", wsId] as const,
@@ -12,3 +13,11 @@ export function runtimeListOptions(wsId: string) {
queryFn: () => api.listRuntimes({ workspace_id: wsId }),
});
}
export function latestCliVersionOptions() {
return queryOptions({
queryKey: ["cli", "latest-version"] as const,
queryFn: fetchLatestVersion,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}

View File

@@ -1,21 +1,11 @@
import type { AgentRuntime } from "@/shared/types";
import { formatLastSeen } from "../utils";
import { getCliVersion } from "../version";
import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared";
import { PingSection } from "./ping-section";
import { UpdateSection } from "./update-section";
import { UsageSection } from "./usage-section";
function getCliVersion(metadata: Record<string, unknown>): string | null {
if (
metadata &&
typeof metadata.cli_version === "string" &&
metadata.cli_version
) {
return metadata.cli_version;
}
return null;
}
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
const cliVersion =
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;

View File

@@ -1,14 +1,19 @@
import { Server } from "lucide-react";
import { ArrowUpCircle, Server } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { AgentRuntime } from "@/shared/types";
import { latestCliVersionOptions } from "@core/runtimes/queries";
import { runtimeNeedsUpdate } from "../version";
import { RuntimeModeIcon } from "./shared";
function RuntimeListItem({
runtime,
isSelected,
hasUpdate,
onClick,
}: {
runtime: AgentRuntime;
isSelected: boolean;
hasUpdate: boolean;
onClick: () => void;
}) {
return (
@@ -19,16 +24,28 @@ function RuntimeListItem({
}`}
>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
className={`relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
runtime.status === "online" ? "bg-success/10" : "bg-muted"
}`}
>
<RuntimeModeIcon mode={runtime.runtime_mode} />
{hasUpdate && (
<span className="absolute -top-1 -right-1 size-2.5 rounded-full bg-destructive ring-2 ring-background" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{runtime.name}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{runtime.provider} &middot; {runtime.runtime_mode}
{hasUpdate ? (
<span className="inline-flex items-center gap-1 text-destructive">
<ArrowUpCircle className="h-3 w-3" />
Update available
</span>
) : (
<>
{runtime.provider} &middot; {runtime.runtime_mode}
</>
)}
</div>
</div>
<div
@@ -49,6 +66,8 @@ export function RuntimeList({
selectedId: string;
onSelect: (id: string) => void;
}) {
const { data: latestVersion } = useQuery(latestCliVersionOptions());
return (
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
@@ -79,6 +98,7 @@ export function RuntimeList({
key={runtime.id}
runtime={runtime}
isSelected={runtime.id === selectedId}
hasUpdate={runtimeNeedsUpdate(runtime, latestVersion ?? null)}
onClick={() => onSelect(runtime.id)}
/>
))}

View File

@@ -9,47 +9,7 @@ import {
import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import type { RuntimeUpdateStatus } from "@/shared/types";
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
let cachedLatestVersion: string | null = null;
let cachedAt = 0;
async function fetchLatestVersion(): Promise<string | null> {
if (cachedLatestVersion && Date.now() - cachedAt < CACHE_TTL_MS) {
return cachedLatestVersion;
}
try {
const resp = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;
const data = await resp.json();
cachedLatestVersion = data.tag_name ?? null;
cachedAt = Date.now();
return cachedLatestVersion;
} catch {
return null;
}
}
function stripV(v: string): string {
return v.replace(/^v/, "");
}
function isNewer(latest: string, current: string): boolean {
const l = stripV(latest).split(".").map(Number);
const c = stripV(current).split(".").map(Number);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0;
const cv = c[i] ?? 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
import { fetchLatestVersion, isNewer } from "../version";
const statusConfig: Record<
RuntimeUpdateStatus,

View File

@@ -0,0 +1,67 @@
import type { AgentRuntime } from "@/shared/types";
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
let cachedLatestVersion: string | null = null;
let cachedAt = 0;
export async function fetchLatestVersion(): Promise<string | null> {
if (cachedLatestVersion && Date.now() - cachedAt < CACHE_TTL_MS) {
return cachedLatestVersion;
}
try {
const resp = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;
const data = await resp.json();
cachedLatestVersion = data.tag_name ?? null;
cachedAt = Date.now();
return cachedLatestVersion;
} catch {
return null;
}
}
export function stripV(v: string): string {
return v.replace(/^v/, "");
}
export function isNewer(latest: string, current: string): boolean {
const l = stripV(latest).split(".").map(Number);
const c = stripV(current).split(".").map(Number);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0;
const cv = c[i] ?? 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
export function getCliVersion(
metadata: Record<string, unknown>,
): string | null {
if (
metadata &&
typeof metadata.cli_version === "string" &&
metadata.cli_version
) {
return metadata.cli_version;
}
return null;
}
/** Check if a single runtime has an update available. */
export function runtimeNeedsUpdate(
runtime: AgentRuntime,
latestVersion: string | null,
): boolean {
if (!latestVersion) return false;
if (runtime.runtime_mode !== "local") return false;
const current = getCliVersion(runtime.metadata);
if (!current) return false;
return isNewer(latestVersion, current);
}