diff --git a/packages/core/package.json b/packages/core/package.json index 3fa25b646..a8ada0bf1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,7 +55,6 @@ "./runtimes": "./runtimes/index.ts", "./runtimes/queries": "./runtimes/queries.ts", "./runtimes/mutations": "./runtimes/mutations.ts", - "./runtimes/hooks": "./runtimes/hooks.ts", "./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts", "./dashboard": "./dashboard/index.ts", "./dashboard/queries": "./dashboard/queries.ts", diff --git a/packages/core/runtimes/hooks.ts b/packages/core/runtimes/hooks.ts deleted file mode 100644 index f77304f96..000000000 --- a/packages/core/runtimes/hooks.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useAuthStore } from "../auth"; -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; - // Desktop-managed runtimes are updated by the Desktop app's own auto-updater; - // the platform should not surface CLI update prompts for them. - if (rt.metadata && rt.metadata.launched_by === "desktop") { - 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 a Set of runtime IDs that belong to the current user and have updates available. - * Accepts wsId as parameter so callers outside WorkspaceIdProvider can use it safely. - */ -export function useUpdatableRuntimeIds(wsId: string | undefined): Set { - const userId = useAuthStore((s) => s.user?.id); - const { data: runtimes } = useQuery({ - ...runtimeListOptions(wsId ?? ""), - enabled: !!wsId, - }); - const { data: latestVersion } = useQuery(latestCliVersionOptions()); - - return useMemo(() => { - if (!runtimes || !latestVersion || !userId) return new Set(); - const ids = new Set(); - for (const rt of runtimes) { - if (runtimeNeedsUpdate(rt, latestVersion, userId)) { - ids.add(rt.id); - } - } - return ids; - }, [runtimes, latestVersion, userId]); -} diff --git a/packages/core/runtimes/index.ts b/packages/core/runtimes/index.ts index 1287d6ac1..09f8a0d47 100644 --- a/packages/core/runtimes/index.ts +++ b/packages/core/runtimes/index.ts @@ -1,7 +1,6 @@ export * from "./queries"; export * from "./profiles"; export * from "./mutations"; -export * from "./hooks"; export * from "./models"; export * from "./local-skills"; export * from "./types"; diff --git a/packages/core/runtimes/queries.ts b/packages/core/runtimes/queries.ts index 47d0aaf18..851c0d10f 100644 --- a/packages/core/runtimes/queries.ts +++ b/packages/core/runtimes/queries.ts @@ -12,7 +12,6 @@ export const runtimeKeys = { // by-hour now follows the viewer's tz, like the other reports. usageByHour: (rid: string, days: number, tz: string) => ["runtimes", "usage", "by-hour", rid, days, tz] as const, - latestVersion: () => ["runtimes", "latestVersion"] as const, }; // `tz` is the viewer's IANA name — all reports follow the viewer's tz. @@ -54,25 +53,3 @@ 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 => { - 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 - }); -} diff --git a/packages/views/runtimes/components/machine-cli-section.test.tsx b/packages/views/runtimes/components/machine-cli-section.test.tsx new file mode 100644 index 000000000..782f7efc7 --- /dev/null +++ b/packages/views/runtimes/components/machine-cli-section.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { AgentRuntime } from "@multica/core/types"; +import type { RuntimeMachine } from "./runtime-machines"; + +const mockUpdateSection = vi.hoisted(() => vi.fn()); + +vi.mock("./update-section", () => ({ + UpdateSection: (props: Record) => { + mockUpdateSection(props); + return ; + }, +})); + +import { MachineCliSection } from "./machine-cli-section"; + +function runtime(overrides: Partial = {}): AgentRuntime { + return { + id: "runtime-1", + workspace_id: "ws-1", + daemon_id: "daemon-1", + name: "Claude (dev.local)", + runtime_mode: "local", + provider: "claude", + launch_header: "", + status: "online", + device_info: "dev.local", + metadata: { cli_version: "0.3.17" }, + owner_id: "user-1", + visibility: "private", + last_seen_at: "2026-07-15T00:00:00Z", + created_at: "2026-07-15T00:00:00Z", + updated_at: "2026-07-15T00:00:00Z", + ...overrides, + }; +} + +function machine(runtimes: AgentRuntime[]): RuntimeMachine { + return { + id: "local:daemon-1", + daemonId: "daemon-1", + title: "dev.local", + subtitle: null, + deviceInfo: "dev.local", + cliVersion: "0.3.17", + mode: "local", + section: "remote", + isCurrent: false, + health: "online", + runtimes, + onlineCount: runtimes.filter((item) => item.status === "online").length, + issueCount: 0, + runningCount: 0, + queuedCount: 0, + providerNames: runtimes.map((item) => item.provider), + lastSeenAt: "2026-07-15T00:00:00Z", + }; +} + +describe("MachineCliSection", () => { + beforeEach(() => { + mockUpdateSection.mockClear(); + }); + + it("renders one update control using an online viewer-owned runtime", () => { + const offlineOwned = runtime({ id: "offline-owned", status: "offline" }); + const onlineOwned = runtime({ + id: "online-owned", + metadata: { + cli_version: "0.3.17", + launched_by: "desktop", + }, + }); + const otherUsers = runtime({ id: "other-user", owner_id: "user-2" }); + + render( + , + ); + + expect(screen.getAllByRole("button", { name: "Update" })).toHaveLength(1); + expect(mockUpdateSection).toHaveBeenCalledTimes(1); + expect(mockUpdateSection).toHaveBeenCalledWith({ + runtimeId: "online-owned", + currentVersion: "0.3.17", + isOnline: true, + launchedBy: "desktop", + }); + }); + + it("shows read-only machine CLI version when the viewer owns no runtime", () => { + render( + , + ); + + expect(screen.getByText("CLI 0.3.17")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + expect(mockUpdateSection).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/views/runtimes/components/machine-cli-section.tsx b/packages/views/runtimes/components/machine-cli-section.tsx new file mode 100644 index 000000000..655222d09 --- /dev/null +++ b/packages/views/runtimes/components/machine-cli-section.tsx @@ -0,0 +1,51 @@ +import type { AgentRuntime } from "@multica/core/types"; +import type { RuntimeMachine } from "./runtime-machines"; +import { UpdateSection } from "./update-section"; + +function launchedBy(runtime: AgentRuntime): string | null { + const value = runtime.metadata?.launched_by; + return typeof value === "string" && value ? value : null; +} + +/** + * Pick one viewer-owned runtime as the command channel for a machine-wide + * daemon update. An online runtime wins so the daemon can receive the request + * immediately; an offline row still keeps version/managed-state display + * available without enabling the update action. + */ +export function machineUpdateRuntime( + machine: RuntimeMachine, + currentUserId: string | undefined, +): AgentRuntime | null { + if (machine.mode !== "local" || !currentUserId) return null; + + const owned = machine.runtimes.filter( + (runtime) => runtime.owner_id === currentUserId, + ); + return owned.find((runtime) => runtime.status === "online") ?? owned[0] ?? null; +} + +export function MachineCliSection({ + machine, + currentUserId, +}: { + machine: RuntimeMachine; + currentUserId: string | undefined; +}) { + const updateRuntime = machineUpdateRuntime(machine, currentUserId); + + if (!updateRuntime) { + return machine.cliVersion ? ( + CLI {machine.cliVersion} + ) : null; + } + + return ( + + ); +} diff --git a/packages/views/runtimes/components/runtime-detail-page.tsx b/packages/views/runtimes/components/runtime-detail-page.tsx index e4e9b4e2e..018cfc825 100644 --- a/packages/views/runtimes/components/runtime-detail-page.tsx +++ b/packages/views/runtimes/components/runtime-detail-page.tsx @@ -9,7 +9,6 @@ import { useWorkspacePaths } from "@multica/core/paths"; import { agentTaskSnapshotOptions } from "@multica/core/agents"; import { runtimeProfileListOptions } from "@multica/core/runtimes"; import { runtimeKeys, runtimeListOptions } from "@multica/core/runtimes/queries"; -import { useUpdatableRuntimeIds } from "@multica/core/runtimes/hooks"; import { useWSEvent } from "@multica/core/realtime"; import { agentListOptions, @@ -26,6 +25,7 @@ import { import { RenameMachineDialog } from "./rename-machine-dialog"; import { RuntimeProfilesDialog } from "./runtime-profiles-dialog"; import { pendingRuntimesForProfiles } from "./pending-runtime"; +import { MachineCliSection } from "./machine-cli-section"; import { HealthIcon, useHealthLabel } from "./shared"; import { useT, useTimeAgo } from "../../i18n"; @@ -100,7 +100,6 @@ export function RuntimeDetailPage({ const { data: runtimeProfiles = [] } = useQuery( runtimeProfileListOptions(wsId), ); - const updatableIds = useUpdatableRuntimeIds(wsId); const now = useNowTick(); const machineLocator = decodeRouteParam(runtimeId); const [renameOpen, setRenameOpen] = useState(false); @@ -259,9 +258,10 @@ export function RuntimeDetailPage({ }) : t(($) => $.machine.metrics.workload_idle)} - {machine.cliVersion && ( - CLI {machine.cliVersion} - )} + {machine.lastSeenAt && ( {timeAgo(machine.lastSeenAt)} )} @@ -313,7 +313,6 @@ export function RuntimeDetailPage({
paths.runtimeSettings(machine.id, childRuntimeId) diff --git a/packages/views/runtimes/components/runtime-detail-visibility.test.tsx b/packages/views/runtimes/components/runtime-detail-visibility.test.tsx index af5b2fdef..e65bc3442 100644 --- a/packages/views/runtimes/components/runtime-detail-visibility.test.tsx +++ b/packages/views/runtimes/components/runtime-detail-visibility.test.tsx @@ -118,10 +118,9 @@ vi.mock("@multica/core/runtimes/mutations", () => ({ }), })); -// Stubbing ProviderLogo / UsageSection / UpdateSection avoids dragging in -// chart libs and additional query keys we don't care about here. +// Stubbing ProviderLogo / UsageSection avoids dragging in chart libs and +// additional query keys we don't care about here. vi.mock("./provider-logo", () => ({ ProviderLogo: () => null })); -vi.mock("./update-section", () => ({ UpdateSection: () => null })); vi.mock("./usage-section", () => ({ UsageSection: () => null })); vi.mock("./shared", () => ({ HealthBadge: () => null })); vi.mock("../../agents/presence", () => ({ @@ -201,6 +200,24 @@ describe("RuntimeDetail visibility section", () => { expect(screen.getByText("Public")).toBeInTheDocument(); }); + it("keeps daemon CLI version details without rendering update controls", () => { + renderDetail( + makeRuntime({ + metadata: { cli_version: "0.3.17" }, + runtime_mode: "local", + }), + ); + + fireEvent.click( + screen.getByRole("button", { name: "Technical details" }), + ); + + expect(screen.getByText("0.3.17")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + }); + it("flips visibility to public when the owner clicks the Public choice", async () => { renderDetail(makeRuntime({ owner_id: "user-me", visibility: "private" })); fireEvent.click(screen.getByText("Public")); diff --git a/packages/views/runtimes/components/runtime-detail.tsx b/packages/views/runtimes/components/runtime-detail.tsx index c152fe290..af497bb7d 100644 --- a/packages/views/runtimes/components/runtime-detail.tsx +++ b/packages/views/runtimes/components/runtime-detail.tsx @@ -42,7 +42,6 @@ import { AppLink, useNavigation } from "../../navigation"; import { availabilityConfig, workloadConfig } from "../../agents/presence"; import { HealthBadge } from "./shared"; import { ProviderLogo } from "./provider-logo"; -import { UpdateSection } from "./update-section"; import { UsageSection } from "./usage-section"; import { DeleteRuntimeDialog } from "./delete-runtime-dialog"; import { DeleteRuntimeProfileDialog } from "./delete-runtime-profile-dialog"; @@ -59,17 +58,6 @@ function getCliVersion(metadata: Record): string | null { return null; } -function getLaunchedBy(metadata: Record): string | null { - if ( - metadata && - typeof metadata.launched_by === "string" && - metadata.launched_by - ) { - return metadata.launched_by; - } - return null; -} - function shortDaemonId(id: string | null): string | null { if (!id) return null; if (id.length <= 10) return id; @@ -105,9 +93,6 @@ export function RuntimeDetail({ const timeAgo = useTimeAgo(); const cliVersion = runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null; - const launchedBy = - runtime.runtime_mode === "local" ? getLaunchedBy(runtime.metadata) : null; - const user = useAuthStore((s) => s.user); const wsId = useWorkspaceId(); const paths = useWorkspacePaths(); @@ -215,8 +200,6 @@ export function RuntimeDetail({ /> setDeleteOpen(true)} @@ -484,21 +467,16 @@ function ServingAgentsCard({ function DiagnosticsCard({ runtime, - cliVersion, - launchedBy, canEdit, canDelete, onDelete, }: { runtime: AgentRuntime; - cliVersion: string | null; - launchedBy: string | null; canEdit: boolean; canDelete: boolean; onDelete: () => void; }) { const { t } = useT("runtimes"); - const isLocal = runtime.runtime_mode === "local"; return (
@@ -515,19 +493,6 @@ function DiagnosticsCard({ )}
- {isLocal && ( -
-
- {t(($) => $.detail.diagnostics_cli)} -
- -
- )} {canDelete && ( // The button stays clickable even when the runtime is a live // local daemon (self-healing). The owner explicitly asked for diff --git a/packages/views/runtimes/components/runtime-list.tsx b/packages/views/runtimes/components/runtime-list.tsx index 8a604cb35..c54d0d460 100644 --- a/packages/views/runtimes/components/runtime-list.tsx +++ b/packages/views/runtimes/components/runtime-list.tsx @@ -464,8 +464,8 @@ export function CliCell({ runtime }: { runtime: AgentRuntime }) { // The separate `cli_version` is the shared multica daemon CLI, identical // for every runtime on one machine; surfacing it here made all agents // show the same number (#3838). The daemon CLI version and its update - // prompt belong to the machine — they live in the machine meta strip and - // the detail page's UpdateSection, not on a per-agent row. + // prompt belong to the machine — they live in the machine header, not on a + // per-agent row. const version = meta && typeof meta.version === "string" ? meta.version : null; @@ -613,23 +613,14 @@ export function RuntimeRowMenu({ export function RuntimeList({ runtimes, - updatableIds, now, runtimeHref, }: { runtimes: AgentRuntime[]; - // Kept on the API surface for callers, but unused here: the CLI column - // shows each agent's own tool version, while the multica daemon CLI - // update prompt lives at the machine/detail level (UpdateSection), so the - // table no longer derives per-row update state. Left to avoid scope creep - // on the page-level wrapper that still computes the set. - updatableIds?: Set; now: number; /** Machine-detail pages keep runtime settings nested under the machine. */ runtimeHref?: (runtimeId: string) => string; }) { - void updatableIds; - const { t } = useT("runtimes"); const wsId = useWorkspaceId(); const wsPaths = useWorkspacePaths();