Compare commits

..
Author SHA1 Message Date
l0ng-ai 6d4b127373 feat(plugins): show available updates on the Installed tab
Installed plugins with a newer, loadable marketplace version get an Update
badge in the list and an "Update to x.y.z" button in their details, which
installs it from the marketplace.
2026-11-11 12:26:56 +08:00
6 changed files with 63 additions and 2 deletions

No files matched your search

+6
View File
@@ -1546,6 +1546,12 @@
.planCurrent {
color: var(--fg);
}
.trayActivity {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A one-button toolbar, not a round bubble: the popup's large arc would turn it into a circle. */
.quotePopup {
display: flex;
+6 -1
View File
@@ -75,7 +75,7 @@ import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { useAgentSettings } from "./agentSettings";
import { foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
import { currentTool, foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
import { useRevealedText } from "./reveal";
import { AgentReply, MessageActions, ReplyActions } from "./AgentReply";
import {
@@ -2388,6 +2388,7 @@ function Tray({
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
const busy = task.status === "running" || task.status === "starting";
const tool = entries && busy ? currentTool(entries) : null;
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
return (
<div className={styles.tray}>
@@ -2444,6 +2445,10 @@ function Tray({
<Spinner size={12} />
{task.retry ? (
<Retrying retry={task.retry} />
) : tool ? (
<span className={styles.trayActivity} title={tool.title}>
{shortTitle(task, tool.title || tool.kind)}
</span>
) : (
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
)}
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentEntry } from "@/bindings";
import { foldedTurns, summarizeWork, workParts } from "./timeline";
import { currentTool, foldedTurns, summarizeWork, workParts } from "./timeline";
const user = (text: string, extra: Partial<Extract<AgentEntry, { type: "user" }>> = {}): AgentEntry => ({
type: "user",
@@ -45,6 +45,11 @@ describe("summarizeWork", () => {
{ text: "1 other tool call", count: 1 },
]);
});
it("finds the call in progress", () => {
expect(currentTool([tool("read", { status: "running" }), tool("search")])?.kind).toBe("read");
expect(currentTool([tool("read")])).toBeNull();
});
});
describe("foldedTurns", () => {
+9
View File
@@ -91,6 +91,15 @@ export function workParts(s: WorkSummary): Array<{ text: string; count: number }
return parts.filter(([, , n]) => n > 0).map(([one, many, count]) => ({ text: count === 1 ? one : many, count }));
}
/** The running tool call of a run, if any: what the agent is doing right now. */
export function currentTool(entries: readonly AgentEntry[]): ToolEntry | null {
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!;
if (e.type === "tool" && (e.status === "running" || e.status === "pending")) return e;
}
return null;
}
/** A finished turn: the message that asked it at `user`, its work in `[user + 1, reply)`. */
export interface FoldedTurn {
user: number;
+23
View File
@@ -129,6 +129,15 @@ export function catalogState(
const vendorOf = (authors: readonly string[]) => authors.join(", ");
/** The marketplace's newer version of an installed plugin, if there is one this IDE can load. */
function useMarketUpdate(plugin: PluginInfo): MarketPlugin | undefined {
return usePlugins((s) =>
plugin.source === "installed"
? s.market?.find((m) => m.id === plugin.id && m.compatible && compareVersions(m.version, plugin.version) > 0)
: undefined,
);
}
function PluginIcon({ id, icon, large }: { id: string; icon: PluginIconData | null | undefined; large?: boolean }) {
return (
<span className={clsx(styles.icon, large && styles.iconLarge)} aria-hidden>
@@ -148,6 +157,7 @@ function ListHeader({ title, count }: { title: string; count: number }) {
function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; selected: boolean; onSelect: () => void }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
const update = useMarketUpdate(plugin);
return (
<li className={clsx(styles.row, selected && styles.rowSelected, !plugin.enabled && styles.rowDisabled)}>
<button
@@ -166,6 +176,7 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
{plugin.source !== "installed" && (
<span className={styles.devTag}>{plugin.source === "linked" ? tx("linked") : tx("dev")}</span>
)}
{update && <Badge tone="accent">{tx("Update")}</Badge>}
{plugin.status === "failed" && (
<Badge dot tone="danger">
<T>Failed</T>
@@ -544,6 +555,8 @@ function Homepage({ value }: { value: string }) {
function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
const installing = usePlugins((s) => s.installing);
const update = useMarketUpdate(plugin);
const status = STATUS[plugin.status];
const [confirmUninstall, setConfirmUninstall] = useState(false);
@@ -646,6 +659,16 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
</div>
<div className={styles.headerActions}>
{busy && <Spinner size={14} label="Working" />}
{update && (
<Button
size="small"
variant="primary"
disabled={busy || installing}
onClick={() => void installFromMarket(update.id)}
>
{tx`Update to ${update.version}`}
</Button>
)}
<Button
size="small"
variant={plugin.enabled ? "secondary" : "primary"}
@@ -1354,6 +1354,19 @@ describe("marketplace", () => {
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["a", "b"]));
});
it("marks installed plugins with updates and updates one from its details", async () => {
setConsentPrompt(async () => ({ optional: [] }));
const consents = updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
const user = userEvent.setup();
render(<PluginsPage />);
await waitFor(() => expect(usePlugins.getState().market).toHaveLength(3));
const list = screen.getByRole("list", { name: "Installed plugins" });
expect(within(list).getAllByText("Update")).toHaveLength(2);
await user.click(within(list).getAllByRole("button", { name: /^b/i })[0]!);
await user.click(await screen.findByRole("button", { name: "Update to 2.0.0" }));
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["b"]));
});
it("announces updates with actions to install them or show the plugins", async () => {
updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
handlePluginEvent({