Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
2a7e7b98f8 feat(runtimes): proactively show update badges for outdated runtimes
Add update notification indicators so users don't need to navigate into
Runtime Detail to discover available updates:

- Sidebar: red dot on "Runtimes" menu item when any runtime has an update
- Runtime list: arrow icon with tooltip on individual items needing updates
- Shared hook (useRuntimeUpdateCount/useRuntimesWithUpdates) that checks
  GitHub releases against each local runtime's CLI version via TanStack Query
2026-04-08 17:33:52 +08:00
4 changed files with 137 additions and 6 deletions

View File

@@ -47,6 +47,7 @@ import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@core/inbox/queries";
import { api } from "@/shared/api";
import { useModalStore } from "@/features/modals";
import { useRuntimeUpdateCount } from "@core/runtimes/use-runtime-updates";
const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@@ -67,6 +68,13 @@ function DraftDot() {
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
function RuntimeUpdateDot() {
const wsId = useWorkspaceStore((s) => s.workspace?.id);
const count = useRuntimeUpdateCount(wsId);
if (count === 0) return null;
return <span className="size-1.5 rounded-full bg-brand" />;
}
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
@@ -224,6 +232,7 @@ export function AppSidebar() {
>
<item.icon />
<span>{item.label}</span>
{item.label === "Runtimes" && <RuntimeUpdateDot />}
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { runtimeListOptions } from "./queries";
import type { AgentRuntime } from "@/shared/types";
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
export const latestVersionKeys = {
latest: ["github", "latest-version"] as const,
};
async function fetchLatestVersion(): Promise<string | null> {
try {
const resp = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;
const data = await resp.json();
return data.tag_name ?? null;
} 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;
}
function getCliVersion(runtime: AgentRuntime): string | null {
const v = runtime.metadata?.cli_version;
return typeof v === "string" && v ? v : null;
}
/**
* Returns the count of local runtimes that have an available update.
* Uses TanStack Query for caching (10 min stale time for GitHub check).
*/
export function useRuntimeUpdateCount(wsId: string | undefined) {
const { data: latestVersion } = useQuery({
queryKey: latestVersionKeys.latest,
queryFn: fetchLatestVersion,
staleTime: 10 * 60 * 1000, // 10 minutes
enabled: !!wsId,
});
const { data: runtimes } = useQuery({
...runtimeListOptions(wsId!),
enabled: !!wsId,
});
if (!latestVersion || !runtimes) return 0;
return runtimes.filter((r) => {
if (r.runtime_mode !== "local") return false;
const cv = getCliVersion(r);
return cv ? isNewer(latestVersion, cv) : false;
}).length;
}
/**
* Returns a Set of runtime IDs that have an available update.
*/
export function useRuntimesWithUpdates(wsId: string | undefined) {
const { data: latestVersion } = useQuery({
queryKey: latestVersionKeys.latest,
queryFn: fetchLatestVersion,
staleTime: 10 * 60 * 1000,
enabled: !!wsId,
});
const { data: runtimes } = useQuery({
...runtimeListOptions(wsId!),
enabled: !!wsId,
});
if (!latestVersion || !runtimes) return new Set<string>();
const ids = new Set<string>();
for (const r of runtimes) {
if (r.runtime_mode !== "local") continue;
const cv = getCliVersion(r);
if (cv && isNewer(latestVersion, cv)) {
ids.add(r.id);
}
}
return ids;
}

View File

@@ -1,14 +1,21 @@
import { Server } from "lucide-react";
import { Server, ArrowUpCircle } from "lucide-react";
import type { AgentRuntime } from "@/shared/types";
import { RuntimeModeIcon } from "./shared";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
function RuntimeListItem({
runtime,
isSelected,
hasUpdate,
onClick,
}: {
runtime: AgentRuntime;
isSelected: boolean;
hasUpdate: boolean;
onClick: () => void;
}) {
return (
@@ -31,11 +38,21 @@ function RuntimeListItem({
{runtime.provider} &middot; {runtime.runtime_mode}
</div>
</div>
<div
className={`h-2 w-2 shrink-0 rounded-full ${
runtime.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
<div className="flex shrink-0 items-center gap-2">
{hasUpdate && (
<Tooltip>
<TooltipTrigger className="flex items-center">
<ArrowUpCircle className="h-3.5 w-3.5 text-brand" />
</TooltipTrigger>
<TooltipContent side="left">Update available</TooltipContent>
</Tooltip>
)}
<div
className={`h-2 w-2 rounded-full ${
runtime.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</div>
</button>
);
}
@@ -44,10 +61,12 @@ export function RuntimeList({
runtimes,
selectedId,
onSelect,
updateIds,
}: {
runtimes: AgentRuntime[];
selectedId: string;
onSelect: (id: string) => void;
updateIds?: Set<string>;
}) {
return (
<div className="overflow-y-auto h-full border-r">
@@ -79,6 +98,7 @@ export function RuntimeList({
key={runtime.id}
runtime={runtime}
isSelected={runtime.id === selectedId}
hasUpdate={updateIds?.has(runtime.id) ?? false}
onClick={() => onSelect(runtime.id)}
/>
))}

View File

@@ -13,6 +13,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceId } from "@core/hooks";
import { runtimeListOptions, runtimeKeys } from "@core/runtimes/queries";
import { useRuntimesWithUpdates } from "@core/runtimes/use-runtime-updates";
import { useWSEvent } from "@/features/realtime";
import { RuntimeList } from "./runtime-list";
import { RuntimeDetail } from "./runtime-detail";
@@ -22,6 +23,7 @@ export default function RuntimesPage() {
const wsId = useWorkspaceId();
const qc = useQueryClient();
const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId));
const updateIds = useRuntimesWithUpdates(wsId);
const [selectedId, setSelectedId] = useState("");
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
@@ -95,6 +97,7 @@ export default function RuntimesPage() {
runtimes={runtimes}
selectedId={effectiveSelectedId}
onSelect={setSelectedId}
updateIds={updateIds}
/>
</ResizablePanel>