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
11 changed files with 116 additions and 6 deletions

No files matched your search

+31
View File
@@ -54,6 +54,10 @@
align-items: center;
gap: 2px;
}
.statusActions > .changesToggle {
height: var(--h-control-small);
padding: 0 4px;
}
.statusText {
flex: 1 1 0;
min-width: 0;
@@ -1226,6 +1230,33 @@
text-overflow: ellipsis;
}
.changesToggle {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--h-control);
padding: 0 8px;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-subtle);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.changesToggle:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--fg-strong);
}
.changesToggle[aria-pressed="true"] {
background: var(--bg-selected);
color: var(--fg-strong);
}
.changesToggle:disabled {
opacity: 0.4;
cursor: default;
}
/* An open AskUserQuestion: every question with its options, answered at once above the composer. */
.questionCard {
flex: none;
+41 -4
View File
@@ -272,8 +272,20 @@ function useLocalHead(status: AgentTask["status"] | null, root: string | null):
return local ? head : null;
}
/** The task's terminal, at the end of its status line. */
function StatusActions({ task }: { task: AgentTask }) {
/** The task's terminal and changes, at the end of its status line. */
function StatusActions({
task,
changes,
changesOpen,
onToggleChanges,
}: {
task: AgentTask;
changes: AgentChange[] | null;
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const tx = useTx();
const count = changes?.length ?? 0;
return (
<span className={styles.statusActions}>
<IconButton
@@ -282,6 +294,18 @@ function StatusActions({ task }: { task: AgentTask }) {
title="Terminal"
onClick={() => void executeAction("terminal.openAt", task.cwd)}
/>
<button
type="button"
className={styles.changesToggle}
aria-pressed={changesOpen}
disabled={count === 0 && !changesOpen}
aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")}
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
onClick={onToggleChanges}
>
<Icon name="toolwindow-changes" size={14} />
{count > 0 && <span>{count}</span>}
</button>
</span>
);
}
@@ -291,10 +315,16 @@ function SessionStatus({
task,
entries,
head,
changes,
changesOpen,
onToggleChanges,
}: {
task: AgentTask;
entries: AgentEntry[] | null;
head: string | null;
changes: AgentChange[] | null;
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const tx = useTx();
const project = useProject((s) => s.project);
@@ -327,7 +357,7 @@ function SessionStatus({
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
</span>
<Meters task={task} entries={entries} />
<StatusActions task={task} />
<StatusActions task={task} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges} />
</div>
);
}
@@ -2529,7 +2559,14 @@ function SessionComposer({
);
return (
<div className={styles.composerArea}>
<SessionStatus task={task} entries={entries} head={head} />
<SessionStatus
task={task}
entries={entries}
head={head}
changes={changes}
changesOpen={changesOpen}
onToggleChanges={onToggleChanges}
/>
{task.status === "archived" && (
<div className={styles.banner}>
<span className={styles.bannerText}>
+2 -2
View File
@@ -1124,14 +1124,14 @@ describe("session state across tasks and hosts", () => {
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
await screen.findByText("Answer A");
await screen.findByRole("button", { name: /1 changed file/ });
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
expect(screen.queryByRole("complementary", { name: "Changes" })).toBeNull();
const markVisible = vi.spyOn(await import("./store"), "markVisible");
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const message = screen.getByRole("textbox", { name: "Message" });
fireEvent.change(message, { target: { value: "Draft to keep" } });
const conversation = stream();
fireEvent.click(screen.getByRole("button", { name: /1 changed file/ }));
fireEvent.click(screen.getByRole("button", { name: "Show changes" }));
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
expect(screen.getByText("Answer A")).not.toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
+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({
+1
View File
@@ -2016,6 +2016,7 @@
"Show Commit in Git Log": "Mostrar confirmación en el registro de Git",
"Show Container Log": "Mostrar registro del contenedor",
"Show Context Actions": "Mostrar acciones de contexto",
"Show changes": "Mostrar cambios",
"Show Coverage": "Mostrar cobertura",
"Show Debug Tool Window": "Mostrar ventana de herramientas de depuración",
"Show Diff": "Mostrar diferencias",
+1
View File
@@ -1928,6 +1928,7 @@
"Show Commit in Git Log": "Afficher le commit dans le journal Git",
"Show Container Log": "Afficher le journal du conteneur",
"Show Context Actions": "Afficher les actions contextuelles",
"Show changes": "Afficher les modifications",
"Show Coverage": "Afficher la couverture",
"Show Debug Tool Window": "Afficher la fenêtre d'outil Débogage",
"Show Diff": "Afficher le diff",
+1
View File
@@ -2072,6 +2072,7 @@
"Show Commit in Git Log": "Git 로그에서 커밋 표시",
"Show Container Log": "컨테이너 로그 표시",
"Show Context Actions": "컨텍스트 액션 표시",
"Show changes": "변경 사항 표시",
"Show Coverage": "커버리지 표시",
"Show Debug Tool Window": "디버그 도구 창 표시",
"Show Diff": "Diff 표시",
+1
View File
@@ -2003,6 +2003,7 @@
"Show Commit in Git Log": "Pokaż zatwierdzenie w dzienniku Git",
"Show Container Log": "Pokaż dziennik kontenera",
"Show Context Actions": "Pokaż akcje kontekstowe",
"Show changes": "Pokaż zmiany",
"Show Coverage": "Pokaż pokrycie kodu",
"Show Debug Tool Window": "Pokaż okno narzędziowe debugowania",
"Show Diff": "Pokaż różnice (Diff)",
+1
View File
@@ -2004,6 +2004,7 @@
"Show Commit in Git Log": "Mostrar commit no log do Git",
"Show Container Log": "Mostrar log do contêiner",
"Show Context Actions": "Mostrar ações de contexto",
"Show changes": "Mostrar alterações",
"Show Coverage": "Mostrar cobertura",
"Show Debug Tool Window": "Mostrar janela de ferramentas de depuração",
"Show Diff": "Mostrar diff",
+1
View File
@@ -2364,6 +2364,7 @@
"Show Commit in Git Log": "在 Git 日志中显示提交",
"Show Container Log": "显示容器日志",
"Show Context Actions": "显示上下文操作",
"Show changes": "显示更改",
"Show Coverage": "显示覆盖率",
"Show Debug Tool Window": "显示调试工具窗口",
"Show Diff": "显示差异",