diff --git a/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx b/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx index 4700b288d..ef1ddb7a9 100644 --- a/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx +++ b/packages/views/agents/components/runtime-machine-filter-dropdown.test.tsx @@ -21,6 +21,7 @@ function makeMachine( subtitle: "x86_64 macOS", deviceInfo: "dev.local ยท x86_64 macOS", cliVersion: "1.0.0", + launchedBy: null, mode: "local", section: "local", isCurrent: true, diff --git a/packages/views/runtimes/components/machine-cli-section.test.tsx b/packages/views/runtimes/components/machine-cli-section.test.tsx index 782f7efc7..17888ba25 100644 --- a/packages/views/runtimes/components/machine-cli-section.test.tsx +++ b/packages/views/runtimes/components/machine-cli-section.test.tsx @@ -10,7 +10,11 @@ const mockUpdateSection = vi.hoisted(() => vi.fn()); vi.mock("./update-section", () => ({ UpdateSection: (props: Record) => { mockUpdateSection(props); - return ; + return props.runtimeId ? ( + + ) : ( + CLI status + ); }, })); @@ -38,6 +42,11 @@ function runtime(overrides: Partial = {}): AgentRuntime { } function machine(runtimes: AgentRuntime[]): RuntimeMachine { + const launchedBy = runtimes + .filter((item) => item.status === "online") + .map((item) => item.metadata?.launched_by) + .find((value): value is string => typeof value === "string"); + return { id: "local:daemon-1", daemonId: "daemon-1", @@ -45,6 +54,7 @@ function machine(runtimes: AgentRuntime[]): RuntimeMachine { subtitle: null, deviceInfo: "dev.local", cliVersion: "0.3.17", + launchedBy: launchedBy ?? null, mode: "local", section: "remote", isCurrent: false, @@ -92,18 +102,31 @@ describe("MachineCliSection", () => { }); }); - it("shows read-only machine CLI version when the viewer owns no runtime", () => { + it("shows read-only machine CLI status when the viewer owns no runtime", () => { render( , ); - expect(screen.getByText("CLI 0.3.17")).toBeInTheDocument(); + expect(screen.getByText("CLI status")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Update" }), ).not.toBeInTheDocument(); - expect(mockUpdateSection).not.toHaveBeenCalled(); + expect(mockUpdateSection).toHaveBeenCalledWith({ + runtimeId: null, + currentVersion: "0.3.17", + isOnline: false, + launchedBy: "desktop", + }); }); }); diff --git a/packages/views/runtimes/components/machine-cli-section.tsx b/packages/views/runtimes/components/machine-cli-section.tsx index 655222d09..3e8d06dde 100644 --- a/packages/views/runtimes/components/machine-cli-section.tsx +++ b/packages/views/runtimes/components/machine-cli-section.tsx @@ -2,11 +2,6 @@ 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 @@ -34,18 +29,30 @@ export function MachineCliSection({ }) { const updateRuntime = machineUpdateRuntime(machine, currentUserId); - if (!updateRuntime) { + if (machine.mode !== "local") { return machine.cliVersion ? ( CLI {machine.cliVersion} ) : null; } + // A viewer's ability to send an update command must not gate the + // machine-level version and manager information. The only local machine + // without anything to report is Desktop's synthesized stopped-daemon row. + if ( + !updateRuntime && + machine.runtimes.length === 0 && + !machine.cliVersion && + !machine.launchedBy + ) { + return null; + } + return ( ); } diff --git a/packages/views/runtimes/components/runtime-machines.test.ts b/packages/views/runtimes/components/runtime-machines.test.ts index b2887f338..fc9f5b1c0 100644 --- a/packages/views/runtimes/components/runtime-machines.test.ts +++ b/packages/views/runtimes/components/runtime-machines.test.ts @@ -53,6 +53,30 @@ describe("runtime machine grouping", () => { }); }); + it("uses the online daemon CLI version instead of a stale offline report", () => { + const machines = buildRuntimeMachines( + [ + makeRuntime({ + id: "rt-online", + provider: "claude", + metadata: { cli_version: "0.4.0", launched_by: "desktop" }, + }), + makeRuntime({ + id: "rt-stale", + provider: "copilot", + status: "offline", + last_seen_at: new Date(NOW - 4 * 24 * 60 * 60_000).toISOString(), + metadata: { cli_version: "0.3.17", launched_by: "desktop" }, + }), + ], + { now: NOW }, + ); + + expect(machines).toHaveLength(1); + expect(machines[0]?.cliVersion).toBe("0.4.0"); + expect(machines[0]?.launchedBy).toBe("desktop"); + }); + it("uses a machine-wide custom name as the machine title, over the local name", () => { const machines = buildRuntimeMachines( [ diff --git a/packages/views/runtimes/components/runtime-machines.ts b/packages/views/runtimes/components/runtime-machines.ts index 18b3680f6..b99141479 100644 --- a/packages/views/runtimes/components/runtime-machines.ts +++ b/packages/views/runtimes/components/runtime-machines.ts @@ -17,6 +17,7 @@ export interface RuntimeMachine { subtitle: string | null; deviceInfo: string | null; cliVersion: string | null; + launchedBy: string | null; mode: AgentRuntime["runtime_mode"]; section: RuntimeMachineSection; isCurrent: boolean; @@ -133,6 +134,7 @@ function placeholderLocalMachine( subtitle: null, deviceInfo: null, cliVersion: null, + launchedBy: null, mode: "local", section: "local", isCurrent: true, @@ -252,7 +254,8 @@ function finalizeRuntimeMachine( title, subtitle, deviceInfo, - cliVersion: commonCliVersion(runtimes), + cliVersion: currentMachineMetadata(runtimes, "cli_version"), + launchedBy: currentMachineMetadata(runtimes, "launched_by"), mode: draft.mode, section: isCurrent ? "local" : draft.mode === "cloud" ? "cloud" : "remote", isCurrent, @@ -383,15 +386,28 @@ function latestLastSeenAt(runtimes: AgentRuntime[]): string | null { return latest; } -function commonCliVersion(runtimes: AgentRuntime[]): string | null { - const versions = new Set(); - for (const runtime of runtimes) { - const version = runtime.metadata?.cli_version; - if (typeof version === "string" && version.trim()) { - versions.add(version.trim()); - } +function currentMachineMetadata( + runtimes: AgentRuntime[], + key: "cli_version" | "launched_by", +): string | null { + const online = runtimes.filter((runtime) => runtime.status === "online"); + const candidates = online.length > 0 ? online : runtimes; + + for (const runtime of candidates.toSorted(compareRuntimeReports)) { + const value = runtime.metadata?.[key]; + if (typeof value === "string" && value.trim()) return value.trim(); } - return versions.size === 1 ? Array.from(versions)[0] ?? null : null; + return null; +} + +function compareRuntimeReports(a: AgentRuntime, b: AgentRuntime): number { + return runtimeReportTime(b) - runtimeReportTime(a); +} + +function runtimeReportTime(runtime: AgentRuntime): number { + const reportedAt = runtime.last_seen_at ?? runtime.updated_at; + const timestamp = Date.parse(reportedAt); + return Number.isNaN(timestamp) ? 0 : timestamp; } function shortDaemonId(daemonId: string): string { diff --git a/packages/views/runtimes/components/update-section.test.tsx b/packages/views/runtimes/components/update-section.test.tsx new file mode 100644 index 000000000..97ebeac9f --- /dev/null +++ b/packages/views/runtimes/components/update-section.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enRuntimes from "../../locales/en/runtimes.json"; +import { UpdateSection } from "./update-section"; + +const TEST_RESOURCES = { en: { common: enCommon, runtimes: enRuntimes } }; + +vi.mock("@multica/core/api", () => ({ + api: { + initiateUpdate: vi.fn(), + getUpdateResult: vi.fn(), + }, +})); + +function renderSection(props: { + runtimeId: string | null; + launchedBy?: string | null; + currentVersion?: string; +}) { + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("UpdateSection read-only status", () => { + it("shows Latest without exposing an update action", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ tag_name: "v0.4.0" }), + }), + ); + + renderSection({ runtimeId: null }); + + expect(await screen.findByText("Latest")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + }); + + it("shows the Desktop manager without exposing an update action", () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ tag_name: "v0.4.0" }), + }), + ); + + renderSection({ runtimeId: null, launchedBy: "desktop" }); + + expect(screen.getByText("Managed by Desktop")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + }); + + it("shows an available version without an action for a read-only viewer", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ tag_name: "v0.4.0" }), + }), + ); + + renderSection({ runtimeId: null, currentVersion: "v0.3.17" }); + + expect(await screen.findByText("available")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/runtimes/components/update-section.tsx b/packages/views/runtimes/components/update-section.tsx index 28b54115d..024ee35e2 100644 --- a/packages/views/runtimes/components/update-section.tsx +++ b/packages/views/runtimes/components/update-section.tsx @@ -64,7 +64,8 @@ const statusConfig: Record< }; interface UpdateSectionProps { - runtimeId: string; + /** Null for a read-only viewer who cannot use a runtime as the command channel. */ + runtimeId: string | null; currentVersion: string | null; isOnline: boolean; /** @@ -128,7 +129,7 @@ export function UpdateSection({ }, [currentVersion, markCompleted, targetVersion, updating]); const handleUpdate = async () => { - if (!latestVersion) return; + if (!latestVersion || !runtimeId) return; cleanup(); setUpdating(true); setTargetVersion(latestVersion); @@ -212,7 +213,7 @@ export function UpdateSection({ )} - {hasUpdate && isOnline && !status && ( + {hasUpdate && runtimeId && isOnline && !status && (