Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
4f36003e43 fix(core): add runtimes/hooks to package.json exports 2026-04-09 14:18:54 +08:00
Jiang Bohan
2b4a521bca feat(runtime): proactive CLI update notifications with per-user filtering
- Add latestCliVersionOptions query (GitHub Releases API, 10-min TanStack cache)
- Add useMyRuntimesNeedUpdate / useUpdatableRuntimeIds hooks using owner_id
- Show red dot on sidebar Runtimes item when user's runtimes need updates
- Show update arrow icon alongside status dot in runtime list items
2026-04-09 14:15:21 +08:00
7 changed files with 124 additions and 6 deletions

View File

@@ -47,6 +47,7 @@ import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@/platform/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@@ -86,6 +87,7 @@ export function AppSidebar() {
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate();
const logout = () => {
router.push("/");
@@ -224,6 +226,9 @@ export function AppSidebar() {
>
<item.icon />
<span>{item.label}</span>
{item.label === "Runtimes" && hasRuntimeUpdates && (
<span className="ml-auto size-1.5 rounded-full bg-destructive" />
)}
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -32,6 +32,7 @@
"./runtimes": "./runtimes/index.ts",
"./runtimes/queries": "./runtimes/queries.ts",
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./realtime": "./realtime/index.ts",
"./navigation": "./navigation/index.ts",
"./modals": "./modals/index.ts",

View File

@@ -0,0 +1,73 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "../auth";
import { useWorkspaceId } from "../hooks";
import type { AgentRuntime } from "../types";
import { runtimeListOptions, latestCliVersionOptions } from "./queries";
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 runtimeNeedsUpdate(
rt: AgentRuntime,
latestVersion: string,
userId: string,
): boolean {
if (rt.runtime_mode !== "local") return false;
// Only show to the user who owns this runtime.
if (rt.owner_id !== userId) return false;
const cliVersion =
rt.metadata && typeof rt.metadata.cli_version === "string"
? rt.metadata.cli_version
: null;
if (!cliVersion) return false;
return isNewer(latestVersion, cliVersion);
}
/**
* Returns true if the current user has any local runtime with an outdated CLI version.
*/
export function useMyRuntimesNeedUpdate(): boolean {
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id);
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
const { data: latestVersion } = useQuery(latestCliVersionOptions());
if (!runtimes || !latestVersion || !userId) return false;
return runtimes.some((rt) => runtimeNeedsUpdate(rt, latestVersion, userId));
}
/**
* Returns a Set of runtime IDs that belong to the current user and have updates available.
*/
export function useUpdatableRuntimeIds(): Set<string> {
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id);
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
const { data: latestVersion } = useQuery(latestCliVersionOptions());
return useMemo(() => {
if (!runtimes || !latestVersion || !userId) return new Set<string>();
const ids = new Set<string>();
for (const rt of runtimes) {
if (runtimeNeedsUpdate(rt, latestVersion, userId)) {
ids.add(rt.id);
}
}
return ids;
}, [runtimes, latestVersion, userId]);
}

View File

@@ -1,2 +1,3 @@
export * from "./queries";
export * from "./mutations";
export * from "./hooks";

View File

@@ -5,6 +5,7 @@ export const runtimeKeys = {
all: (wsId: string) => ["runtimes", wsId] as const,
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
listMine: (wsId: string) => [...runtimeKeys.all(wsId), "list", "mine"] as const,
latestVersion: () => ["runtimes", "latestVersion"] as const,
};
export function runtimeListOptions(wsId: string, owner?: "me") {
@@ -13,3 +14,25 @@ export function runtimeListOptions(wsId: string, owner?: "me") {
queryFn: () => api.listRuntimes({ workspace_id: wsId, owner }),
});
}
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
export function latestCliVersionOptions() {
return queryOptions({
queryKey: runtimeKeys.latestVersion(),
queryFn: async (): 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 as string) ?? null;
} catch {
return null;
}
},
staleTime: 10 * 60 * 1000, // 10 minutes
});
}

View File

@@ -1,4 +1,4 @@
import { Server } from "lucide-react";
import { Server, ArrowUpCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -12,11 +12,13 @@ function RuntimeListItem({
runtime,
isSelected,
ownerMember,
hasUpdate,
onClick,
}: {
runtime: AgentRuntime;
isSelected: boolean;
ownerMember: MemberWithUser | null;
hasUpdate: boolean;
onClick: () => void;
}) {
return (
@@ -50,11 +52,18 @@ function RuntimeListItem({
<span className="truncate">{runtime.provider}</span>
</div>
</div>
<div
className={`h-2 w-2 shrink-0 rounded-full ${
runtime.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
<div className="flex items-center gap-1.5 shrink-0">
{hasUpdate && (
<span title="Update available">
<ArrowUpCircle className="h-3.5 w-3.5 text-info" />
</span>
)}
<div
className={`h-2 w-2 rounded-full ${
runtime.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</div>
</button>
);
}
@@ -67,6 +76,7 @@ export function RuntimeList({
onFilterChange,
ownerFilter,
onOwnerFilterChange,
updatableIds,
}: {
runtimes: AgentRuntime[];
selectedId: string;
@@ -75,6 +85,7 @@ export function RuntimeList({
onFilterChange: (filter: RuntimeFilter) => void;
ownerFilter: string | null;
onOwnerFilterChange: (ownerId: string | null) => void;
updatableIds?: Set<string>;
}) {
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
@@ -178,6 +189,7 @@ export function RuntimeList({
runtime={runtime}
isSelected={runtime.id === selectedId}
ownerMember={getOwnerMember(runtime.owner_id)}
hasUpdate={updatableIds?.has(runtime.id) ?? false}
onClick={() => onSelect(runtime.id)}
/>
))}

View File

@@ -13,6 +13,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions, runtimeKeys } from "@multica/core/runtimes/queries";
import { useUpdatableRuntimeIds } from "@multica/core/runtimes/hooks";
import { useWSEvent } from "@multica/core/realtime";
import { RuntimeList } from "./runtime-list";
import { RuntimeDetail } from "./runtime-detail";
@@ -40,6 +41,7 @@ export default function RuntimesPage() {
}, [qc, wsId]);
useWSEvent("daemon:register", handleDaemonEvent);
const updatableIds = useUpdatableRuntimeIds();
// Auto-select first runtime if nothing selected
const effectiveSelectedId = selectedId && runtimes.some((r) => r.id === selectedId)
@@ -105,6 +107,7 @@ export default function RuntimesPage() {
onFilterChange={setFilter}
ownerFilter={ownerFilter}
onOwnerFilterChange={setOwnerFilter}
updatableIds={updatableIds}
/>
</ResizablePanel>