Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export interface AgentStatus {
"config": string;
"version": string | null;
"lockedVersion": string | null;

/**
* LatestVersion is the newest version published to the package registry, or
* nil when it is not knowable -- offline, rate limited, or not an npm
* package. It drives the update dot only, so nil means "say nothing" rather
* than "up to date".
*/
"latestVersion": string | null;
"canInstall": boolean;
"provider": string | null;
"profileId": string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ export interface CatalogItem {
"platforms": string[] | null;
"platformNote": string;
"rank": number;

/**
* PackageManager and PackageName describe how this Agent is installed, so the
* UI can decide whether to offer an update without keeping its own list of
* npm Agents. That list existed and had already drifted: OpenClaw shipped as
* an npm Agent and was missing from it, so it silently lost its update
* button. Empty means the Agent has no package contract.
*/
"packageManager"?: string;
"packageName"?: string;
}

export interface Group {
Expand Down
79 changes: 76 additions & 3 deletions frontend/src/components/AgentManageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

import { OneAgentApiError } from "../backend/errors";
import type { AgentCatalogItem, AgentStatus } from "../types/api";
import { AgentManageRow, compareVersions, isBehind, targetSummary } from "./AgentManageRow";
import { AgentManageRow, compareVersions, isBehind, targetSummary, updateOffer } from "./AgentManageRow";

const launchAgent = vi.fn();

Expand Down Expand Up @@ -33,6 +33,11 @@ const catalogAgent: AgentCatalogItem = {
platforms: ["macos", "linux", "windows"],
platformNote: "",
rank: 1,
// The package contract comes from the catalog, which reads agents.lock.json.
// It used to be a literal in the component, which is how OpenClaw ended up
// without an update button after shipping as an npm Agent.
packageManager: "npm",
packageName: "@openai/codex",
};

function agentStatus(over: Partial<AgentStatus> = {}): AgentStatus {
Expand All @@ -43,6 +48,7 @@ function agentStatus(over: Partial<AgentStatus> = {}): AgentStatus {
config: "/home/u/.codex/config.toml",
version: "0.145.0",
lockedVersion: "0.145.0",
latestVersion: null,
canInstall: true,
provider: "ppio",
profileId: "team",
Expand All @@ -54,12 +60,12 @@ function agentStatus(over: Partial<AgentStatus> = {}): AgentStatus {
};
}

function renderRow(over: Partial<AgentStatus> = {}, profileName = "团队 PPIO") {
function renderRow(over: Partial<AgentStatus> = {}, profileName = "团队 PPIO", catalogOver: Partial<AgentCatalogItem> = {}) {
render(
<MemoryRouter>
<AgentManageRow
agentId="codex"
catalog={catalogAgent}
catalog={{ ...catalogAgent, ...catalogOver }}
status={agentStatus(over)}
providers={{
ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" },
Expand Down Expand Up @@ -287,3 +293,70 @@ describe("targetSummary", () => {
expect(summary.text).toBe("未配置");
});
});

describe("updateOffer", () => {
const npm = catalogAgent;

it("marks an Agent behind the registry", () => {
const offer = updateOffer(npm, agentStatus({ version: "0.145.0", latestVersion: "0.150.0" }));
expect(offer).toEqual({ npm: true, behind: "0.150.0" });
});

it("says nothing when the installed version is current or newer", () => {
// A newer local version is normal -- the user upgraded the Agent themselves
// -- and flagging it would invite a downgrade.
for (const [version, latest] of [["1.2.3", "1.2.3"], ["2.0.0", "1.9.9"]]) {
expect(updateOffer(npm, agentStatus({ version, latestVersion: latest })).behind).toBe("");
}
});

it("says nothing when the registry could not be reached", () => {
// Offline or rate limited. The dot is an affordance, so an unknown answer
// leaves the row exactly as it was rather than claiming either state.
expect(updateOffer(npm, agentStatus({ version: "1.0.0", latestVersion: null })).behind).toBe("");
});

it("offers no update for an Agent that is not installed", () => {
// `npm update -g` on a package that was never installed exits 0 and does
// nothing, so the button would report success while the Agent stayed missing.
const offer = updateOffer(npm, agentStatus({ installed: false, version: null, latestVersion: "9.9.9" }));
expect(offer.npm).toBe(false);
});

it("offers no update for Agents npm does not manage", () => {
// Aider comes from PyPI through uv; a guide-only entry has no package at all.
expect(updateOffer({ ...npm, packageManager: "uv", packageName: "aider-chat" }, agentStatus()).npm).toBe(false);
expect(updateOffer({ ...npm, packageManager: undefined, packageName: undefined }, agentStatus()).npm).toBe(false);
expect(updateOffer(undefined, agentStatus()).npm).toBe(false);
});
});

describe("the update affordance in the row", () => {
it("puts a dot on the update button when a newer version exists", async () => {
renderRow({ version: "0.145.0", latestVersion: "0.150.0" });
const button = screen.getByRole("button", { name: /更新/ });
expect(button.querySelector(".agent-update-dot")).toBeTruthy();
// The version reaches assistive technology through the title, so the dot
// itself is decoration and must not be announced.
expect(button.getAttribute("title")).toContain("0.150.0");
});

it("leaves the button bare when the Agent is current", () => {
renderRow({ version: "0.145.0", latestVersion: "0.145.0" });
const button = screen.getByRole("button", { name: /更新/ });
expect(button.querySelector(".agent-update-dot")).toBeNull();
});

it("hides the update button entirely when the Agent is not installed", () => {
renderRow({ installed: false, version: null, latestVersion: "9.9.9" });
expect(screen.queryByRole("button", { name: /更新/ })).toBeNull();
});

it("reads the npm package name from the catalog, not a list of its own", async () => {
// OpenClaw shipped as an npm Agent and was missing from that list, so it
// lost both its update button and its package name here.
renderRow({}, "团队 PPIO", { packageName: "openclaw" });
await userEvent.click(screen.getByText("详情"));
expect(screen.getByText("openclaw")).toBeTruthy();
});
});
47 changes: 36 additions & 11 deletions frontend/src/components/AgentManageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,28 @@ import type { AgentCatalogItem, AgentStatus, ProfileSummary, StatusResponse } fr
import { AgentIcon, agentTagline } from "./icons/agents";

type Providers = StatusResponse["providers"];
const npmAgents = new Set(["codex", "claude-code", "opencode", "kilo-cli"]);
const npmPackages: Record<string, string> = {
codex: "@openai/codex",
"claude-code": "@anthropic-ai/claude-code",
opencode: "opencode-ai",
"kilo-cli": "@kilocode/cli",
};

/**
* Whether an update can be offered, and the newer version if the registry named
* one.
*
* The npm-managed set used to be a literal in this file, alongside a second
* literal mapping each Agent to its package name. Both had already drifted:
* OpenClaw shipped as an npm Agent and was in neither, so it silently lost its
* update button and its package name in the details. The catalog carries both
* facts now, from the same agents.lock.json the installer reads.
*/
export function updateOffer(agent: AgentCatalogItem | undefined, status: AgentStatus): { npm: boolean; behind: string } {
// Not installed means there is nothing to update. `npm update -g` on a package
// that was never installed exits 0 and does nothing, so offering the button
// would report success while leaving the Agent missing.
const npm = agent?.packageManager === "npm" && status.installed;
if (!npm || !status.version || !status.latestVersion) return { npm: Boolean(npm), behind: "" };
// Only an older local version is an update. A newer one is normal -- the user
// upgraded the Agent themselves -- and flagging it would invite a downgrade.
const behind = compareVersions(status.version, status.latestVersion) < 0 ? status.latestVersion : "";
return { npm: true, behind };
}

/** -1, 0 or 1 comparing dotted numeric versions; non-numeric parts sort last. */
export function compareVersions(left: string, right: string): number {
Expand Down Expand Up @@ -135,6 +150,7 @@ export function AgentManageRow({
// installed is true only when the Agent's command resolved on the managed
// PATH, so it is already the precise "there is something to launch" signal.
const canLaunch = status.installed;
const offer = updateOffer(catalog, status);

const launch = async () => {
setLaunching(true);
Expand Down Expand Up @@ -211,10 +227,19 @@ export function AgentManageRow({
{/* Always in the row, not only when the Agent cannot launch. Configuring
an installed Agent was previously reachable only by opening <details>,
which made the common case the hidden one. */}
{npmAgents.has(agentId) ? (
<button className="button button-secondary" type="button" onClick={() => void update()} disabled={updating || launching || isTaskRunning(taskKey("install", agentId))} title={t("执行 npm update")}>
{offer.npm ? (
<button
className="button button-secondary agent-update-button"
type="button"
onClick={() => void update()}
disabled={updating || launching || isTaskRunning(taskKey("install", agentId))}
title={offer.behind ? t("有新版本 {version},点击更新", { version: offer.behind }) : t("执行 npm update")}
>
<RefreshCw size={15} className={updating ? "spin" : ""} aria-hidden="true" />
{updating ? t("更新中") : t("更新")}
{/* The dot is decoration; the title above is what carries the new
version to a screen reader, so this is not announced twice. */}
{offer.behind && !updating ? <span className="agent-update-dot" aria-hidden="true" /> : null}
</button>
) : null}
<Link
Expand Down Expand Up @@ -260,8 +285,8 @@ export function AgentManageRow({
{target.note ? (
<div className="agent-manage-detail-note"><small>{t("备注")}</small><span>{target.note}</span></div>
) : null}
{npmPackages[agentId] ? (
<div><small>npm</small><span className="agent-manage-detail-code">{npmPackages[agentId]}</span></div>
{catalog?.packageManager === "npm" && catalog.packageName ? (
<div><small>npm</small><span className="agent-manage-detail-code">{catalog.packageName}</span></div>
) : null}
</div>
</details>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ const english = {
"更新完成": "Update complete",
"更新 {name}": "Update {name}",
"执行 npm update": "Run npm update",
"有新版本 {version},点击更新": "Version {version} is available; click to update",
"无法更新 Agent": "Could not update the agent",
"运行时": "Runtimes",
"缺少 {count} 个运行时,安装后即可自动安装对应 Agent。": "{count} runtime(s) missing. Install them to enable automatic agent installation.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/EnvironmentOverviewPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function status(): StatusResponse {
baseUrl: installed ? "https://api.ppio.com/openai" : null,
updatedAt: null,
detected: null,
latestVersion: null,
});
return {
apiVersion: 1,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function statusWith(profiles: ProfileSummary[]): StatusResponse {
config: "/c",
version: "1.0.0",
lockedVersion: "1.0.0",
latestVersion: null,
canInstall: true,
provider: "ppio",
profileId: "team-ppio",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/ProvidersPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function statusWith(agents: Record<string, string | null>): StatusResponse {
config: "/c",
version: "1.0.0",
lockedVersion: "1.0.0",
latestVersion: null,
canInstall: true,
provider,
profileId: null,
Expand All @@ -45,6 +46,7 @@ function statusWith(agents: Record<string, string | null>): StatusResponse {
configMode: "auto" as const,
guideOnly: false,
lockedVersion: "1.0.0",
latestVersion: null,
protocol: "openai" as const,
platforms: ["macos" as const],
platformNote: "",
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,31 @@
filter: brightness(0.94);
}

/* Anchor for the update dot. overflow stays visible because the dot sits
outside the border box, so it cannot reflow the label beside it. */
.agent-update-button {
position: relative;
overflow: visible;
}

/* A newer version is published. Deliberately wordless: the button's title
carries the version for anyone who wants it, and five rows each spelling out
"update available" is noise. The ring matches the surface behind the button so
the dot reads as a badge rather than a smudge over the border it overlaps.
The ring is --surface-subtle because that is the row's own background, and it
tracks the theme with it. */
.agent-update-dot {
position: absolute;
top: -3px;
right: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--blue);
box-shadow: 0 0 0 2px var(--surface-subtle);
pointer-events: none;
}

.agent-manage-details {
grid-area: details;
align-self: end;
Expand Down
Loading
Loading